Login em tela dividida
Formulário à esquerda e mídia à direita com véu em degradê; os campos entram escalonados. No celular a mídia sai e sobra o formulário.
usa o AUT-01
O código
/* ===== login-dividido.css ========================================================== */
/* AUT-01 · Login em tela dividida — reescrito do original do 21st.dev sem React, sem zod e sem
react-hook-form. ENTREGA O INVÓLUCRO E O PAINEL DE MÍDIA, não o formulário: o formulário é o do
hospedeiro (no caso que originou isto, o do Odoo). Os campos do exemplo não validam nada.
Abaixo de 1024 px o painel de mídia não aparece e sobra o formulário, centrado.
Os filhos do formulário entram escalonados, como no original. */
.c-login {
--c-aut-fundo: #000;
--c-aut-tinta: #ebebeb;
--c-aut-tinta-2: #a8a8a8;
--c-aut-linha: rgba(235, 235, 235, .14);
--c-aut-midia-fundo: #0a0a0a;
--c-aut-veu: linear-gradient(to top, rgba(0, 0, 0, .28), transparent 45%);
--c-aut-corte: 1024px; /* documental: o media query abaixo não lê variável */
--c-aut-destaque: #e8ad32;
background: var(--c-aut-fundo);
color: var(--c-aut-tinta);
}
.c-login__cabeca { margin: 0 auto 24px; max-width: 300px; }
.c-login__cabeca h1 { font-size: clamp(2rem, 7vw, 2.6rem); margin: 0 0 8px; line-height: 1.15; letter-spacing: -.01em; }
.c-login__cabeca p { margin: 0; color: var(--c-aut-tinta-2); font-size: .95rem; }
.c-login__form { padding: 32px 24px; }
.c-login__midia { display: none; }
/* a entrada escalonada dos campos: 80 ms entre os filhos do form, teto no sétimo */
@media (prefers-reduced-motion: no-preference) {
.c-login__cabeca, .c-login__form form > * { animation: c-login-entra .5s cubic-bezier(.16, 1, .3, 1) both; }
.c-login__form form > :nth-child(3) { animation-delay: .08s; }
.c-login__form form > :nth-child(4) { animation-delay: .16s; }
.c-login__form form > :nth-child(5) { animation-delay: .24s; }
.c-login__form form > :nth-child(6) { animation-delay: .32s; }
.c-login__form form > :nth-child(n+7) { animation-delay: .4s; }
}
@keyframes c-login-entra { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: none; } }
/* no desktop: formulário à esquerda, mídia à direita */
@media (min-width: 1024px) {
.c-login { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-height: 100vh; }
.c-login__form { display: flex; align-items: center; justify-content: center; padding: 64px 48px; }
.c-login__form > * { width: 100%; max-width: 420px; }
.c-login__cabeca { max-width: none; margin: 0 0 32px; }
.c-login__cabeca h1 { font-size: clamp(2.4rem, 3.2vw, 3rem); }
.c-login__midia { display: block; position: relative; overflow: hidden; background: var(--c-aut-midia-fundo); border-left: 1px solid var(--c-aut-linha); }
.c-login__video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: 58% 50%; }
.c-login__veu { position: absolute; inset: 0; background: var(--c-aut-veu); }
}
/* campos de exemplo — troque pelos do seu hospedeiro */
.c-login__campo { display: grid; gap: 6px; margin-bottom: 16px; }
.c-login__campo label { font-size: .85rem; color: var(--c-aut-tinta-2); }
.c-login__campo input {
height: 44px; padding: 0 12px;
border: 1px solid var(--c-aut-linha); border-radius: 10px;
background: transparent; color: var(--c-aut-tinta); font: inherit;
}
.c-login__campo input:focus-visible { outline: 2px solid var(--c-aut-destaque); outline-offset: 2px; }
.c-login__linha { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; font-size: .85rem; color: var(--c-aut-tinta-2); }
.c-login__linha a { color: var(--c-aut-destaque); }
.c-login__rodape { margin-top: 20px; font-size: .85rem; color: var(--c-aut-tinta-2); text-align: center; }
/* ===== login-dividido.js =========================================================== */
/* AUT-01 · O painel de mídia só carrega onde aparece.
O vídeo só ganha `src` a partir de 1024 px — abaixo disso o painel está em display:none e o
celular não baixa nada. Com "reduzir movimento" fica só o poster, sem vídeo.
O `data-src` e o `data-poster` do markup é que viram `src` e `poster` aqui. */
(function () {
'use strict';
function iniciar() {
var v = document.querySelector('.c-login__video');
if (!v || !window.matchMedia) { return; }
var largo = window.matchMedia('(min-width: 1024px)');
var quieto = window.matchMedia('(prefers-reduced-motion: reduce)');
function carregar() {
if (!largo.matches || v.getAttribute('poster')) { return; }
v.setAttribute('poster', v.getAttribute('data-poster') || '');
if (quieto.matches) { return; }
v.setAttribute('src', v.getAttribute('data-src') || '');
v.muted = true;
var p = v.play();
if (p !== undefined) { p.catch(function () { /* o navegador recusou o autoplay: fica o poster */ }); }
}
carregar();
if (largo.addEventListener) { largo.addEventListener('change', carregar); }
}
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', iniciar); } else { iniciar(); }
})();
/* ===== markup.html ================================================================= */
<!-- AUT-01 · Login em tela dividida. O invólucro é o componente; o <form> é o do seu hospedeiro.
O painel de mídia é decorativo (aria-hidden) e só existe a partir de 1024 px.
O vídeo nasce sem src: quem o põe é o JS, e só na largura em que o painel aparece. -->
<div class="c-login">
<div class="c-login__form">
<div>
<div class="c-login__cabeca">
<h1>Entrar</h1>
<p>Use o e-mail da sua reserva.</p>
</div>
<form action="#" method="post">
<div class="c-login__campo">
<label for="login-email">E-mail</label>
<input id="login-email" name="email" type="email" autocomplete="email" required>
</div>
<div class="c-login__campo">
<label for="login-senha">Senha</label>
<input id="login-senha" name="senha" type="password" autocomplete="current-password" required>
</div>
<div class="c-login__linha">
<label><input type="checkbox" name="lembrar"> Lembrar de mim</label>
<a href="#">Esqueci a senha</a>
</div>
<button type="submit" class="c-botao-origem c-botao-origem--solido" style="width:100%">
<span class="c-botao-origem__circulo" aria-hidden="true"></span>
<span class="c-botao-origem__rotulo">Entrar</span>
</button>
</form>
<p class="c-login__rodape">Não tem conta? <a href="#">Criar conta</a></p>
</div>
</div>
<div class="c-login__midia" aria-hidden="true">
<video class="c-login__video" muted loop playsinline preload="none"
data-src="hero.mp4" data-poster="hero-poster.jpg"></video>
<div class="c-login__veu"></div>
</div>
</div>
/* ===== 10-auth-split-screen.tsx ==================================================== */
"use client";
import * as React from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { motion } from "framer-motion";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Loader2 } from "lucide-react";
// Validation schema for the form
const formSchema = z.object({
email: z.string().email({ message: "Please enter a valid email." }),
password: z
.string()
.min(8, { message: "Password must be at least 8 characters." }),
rememberMe: z.boolean().default(false).optional(),
});
type FormValues = z.infer<typeof formSchema>;
interface AuthFormSplitScreenProps {
logo: React.ReactNode;
title: string;
description: string;
imageSrc: string;
imageAlt: string;
onSubmit: (data: FormValues) => Promise<void>;
forgotPasswordHref: string;
createAccountHref: string;
}
/**
* A responsive, split-screen authentication form component.
* @param logo - The component to be used as the logo (e.g., an SVG or text).
* @param title - The main heading for the form.
* @param description - A short description below the title.
* @param imageSrc - URL for the image to display on the right panel.
* @param imageAlt - Alt text for the image for accessibility.
* @param onSubmit - Async function to handle form submission.
* @param forgotPasswordHref - URL for the "Forgot Password" link.
* @param createAccountHref - URL for the "Create Account" link.
*/
export function AuthFormSplitScreen({
logo,
title,
description,
imageSrc,
imageAlt,
onSubmit,
forgotPasswordHref,
createAccountHref,
}: AuthFormSplitScreenProps) {
const [isLoading, setIsLoading] = React.useState(false);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
password: "",
rememberMe: false,
},
});
const handleFormSubmit = async (data: FormValues) => {
setIsLoading(true);
try {
await onSubmit(data);
} catch (error) {
console.error("Submission failed:", error);
// Optionally handle and display submission errors here
} finally {
setIsLoading(false);
}
};
// Animation variants for staggering children
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { y: 20, opacity: 0 },
visible: { y: 0, opacity: 1 },
};
return (
<div className="relative flex min-h-screen w-full flex-col md:flex-row">
{/* Left Panel: Form */}
<div className="flex w-full flex-col items-center justify-center bg-background p-8 md:w-1/2">
<div className="w-full max-w-md">
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="flex flex-col gap-6"
>
<motion.div variants={itemVariants} className="mb-4">
{logo}
</motion.div>
<motion.div variants={itemVariants} className="text-left">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
<p className="text-sm text-muted-foreground">{description}</p>
</motion.div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleFormSubmit)}
className="space-y-4"
>
<motion.div variants={itemVariants}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<Input
placeholder="email@example.com"
{...field}
disabled={isLoading}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</motion.div>
<motion.div variants={itemVariants}>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="••••••••••••"
{...field}
disabled={isLoading}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</motion.div>
<motion.div
variants={itemVariants}
className="flex items-center justify-between"
>
<FormField
control={form.control}
name="rememberMe"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
disabled={isLoading}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel className="font-normal">
Remember Me
</FormLabel>
</div>
</FormItem>
)}
/>
<a
href={forgotPasswordHref}
className="text-sm font-medium text-primary hover:underline"
>
Forgotten Password
</a>
</motion.div>
<motion.div variants={itemVariants}>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Continue
</Button>
</motion.div>
</form>
</Form>
<motion.p
variants={itemVariants}
className="px-8 text-center text-sm text-muted-foreground"
>
Don't have an account?{" "}
<a
href={createAccountHref}
className="font-medium text-primary hover:underline"
>
Create one here
</a>
.
</motion.p>
</motion.div>
</div>
</div>
{/* Right Panel: Image */}
<div className="relative hidden w-1/2 md:block">
<img
src={imageSrc}
alt={imageAlt}
className="h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent" />
</div>
</div>
);
}
Já rodou em
- trento-pedro · telas do tema no Odoo — login, cadastro e redefinição de senha no desktop, com o vídeo do hero no lugar da imagem (25/09/2026)
Exige
- react
- react-hook-form
- zod
- @hookform/resolvers/zod
- framer-motion
- shadcn/ui (Button, Form*, Input, Checkbox)
- lucide-react (Loader2)
Como se configura
--c-aut-fundo--c-aut-tinta--c-aut-tinta-2--c-aut-linha--c-aut-midia-fundo--c-aut-veu--c-aut-corte
A ficha
O que o 21st.dev entregou
Recebido em 25/09/2026, na rodada de correções das demais telas (quadro do Rafa no Canva correcoes-demais-telas: "fazer uma tela de login adaptada pra desktop"). É o primeiro componente desta pasta que não vai para a home: vai para as telas do tema do Odoo (/web/login, /web/signup, /web/reset_password).
Dependências: React, react-hook-form + @hookform/resolvers/zod + zod (validação no cliente), Framer Motion (motion.div, variantes), primitivos do shadcn/ui (Button, Form*, Input, Checkbox), lucide-react (Loader2), Tailwind. "use client" é do Next.js.
Como funciona:
- Contêiner
min-h-screen,flex-colno celular emd:flex-rowa partir de 768 px. - Esquerda (
md:w-1/2,p-8, centrada): colunamax-w-mdcom logo, títulotext-2xlsemibold, descriçãotext-smapagada, e-mail, senha, linha com "Remember Me" à esquerda e "Forgotten Password" à direita, botão largura total com spinner enquanto envia, e "Don't have an account? Create one here." embaixo. - Direita (
hidden md:block): a imagem emobject-covercom um véufrom-black/20de baixo para cima. - Movimento: o contêiner revela os filhos com
staggerChildren: 0.1; cada item sobe 20 px com fade.
O que entrou na Trento, e como (25/09/2026):
- O corte, não o código. O Odoo desenha o formulário dele (com CSRF, captcha, "chave de acesso", mensagens de erro); o que se reaproveitou foi o layout dividido, o título com descrição e a entrada escalonada. Uma view no
web.login_layout(odoo/views/login-layout-trento.arch.xml) serve às três telas. - Imagem → vídeo do hero da home (decisão do Rafa): o 16:9 (
hero-loop-amplo.mp4), porque cortado para meia tela ele mantém a modelo inteira; o vertical cortaria cabeça ou pé. Só ganhasrca partir de 1024 px, e com "menos movimento" fica o poster. - A partir de 1024 px, não de 768 (o
mddo original): é onde o layout de tablet e desktop da casa muda. Abaixo disso o celular fica como estava, com o título novo em cima. - Entre o cabeçalho e o rodapé do site, não em tela cheia: o visitante continua com a navegação.
- Saíram: "Remember Me" (o Odoo já mantém a sessão), a validação zod (quem valida é o servidor) e o logo dentro do formulário (o cabeçalho já tem o símbolo).
- Títulos e textos em pt-BR, tokens da Trento (
--tr-tinta-2para a descrição, CTA em degradê), e o escalonamento em CSS (tr-auth-entra, 80 ms entre filhos) em vez de Framer Motion.
O que a adaptação da OCTA mudou
- O painel de mídia só ganha
srca partir de 1024 px — o celular não baixa nada - Com
prefers-reduced-motionfica só o poster, sem vídeo
Lacunas do original
- A validação do original é
zod+react-hook-form: sai inteira na adaptação, e com ela a mensagem de erro por campo - A imagem do original é decorativa e não tem tratamento de falha de carregamento
A adaptação da OCTA entrega o invólucro dividido e o painel de mídia, não o formulário: no caso da Trento o formulário é o do Odoo. O demo mostra um formulário de exemplo, que não valida nada.
⚠️ Lista incompleta: este componente não passou por apuração de acessibilidade. O que está acima é o que se observou de passagem, não um laudo.