Gaveta de revelação
Cabeçalho clicável com ícone, título, resumo e seta; a lista abre animando grid-rows 0fr → 1fr, com as linhas entrando escalonadas a 75 ms.
usa o GAV-01
O código
/* ===== gaveta.css ================================================================== */
/* GAV-01 · Gaveta de revelação — reescrita do original do 21st.dev sem React.
Cabeçalho com ícone, título, resumo e seta; o corpo abre animando grid-rows 0fr → 1fr e as
linhas entram escalonadas a 75 ms (o JS numera cada linha em --i).
Três decisões que o original não tem, e que foram pagas em produção:
1. O padrão do CSS é ABERTA, e quem fecha é o JS. Se o script não chegar, a falha ABRE em vez
de esconder o conteúdo. (Antes era o contrário, com um html.no-js reabrindo — mas qualquer
script que rodasse tirava o no-js, e um JS que estourasse antes deste deixava o conteúdo
inacessível.)
2. Sem `overflow: hidden` no cartão (o original tem): recortava o anel de foco do cabeçalho,
que fica 3 px para fora. Quem recorta o corpo na animação é o .c-gaveta__dentro.
3. O primeiro fechamento não anima. Como ela nasce aberta, o fechar inicial é uma mudança de
estado como outra e o navegador a animava — medido no Chromium: o corpo aparecia com ~554 px
e descia por 0,5 s a cada carregamento. O JS põe .c-gaveta--sem-transicao, fecha, força o
recálculo e tira a classe no quadro seguinte. */
.c-gaveta--sem-transicao, .c-gaveta--sem-transicao * { transition: none !important; }
.c-gaveta {
--c-gav-superficie: #141414;
--c-gav-superficie-2: #1f1f1f;
--c-gav-linha: rgba(235, 235, 235, .12);
--c-gav-tinta: #ebebeb;
--c-gav-tinta-2: #a8a8a8;
--c-gav-tinta-3: #6a6a6a;
--c-gav-raio: 24px;
--c-gav-curva: cubic-bezier(.4, 0, .2, 1);
border: 1px solid var(--c-gav-linha);
border-radius: var(--c-gav-raio);
background: var(--c-gav-superficie);
transition: border-radius .5s var(--c-gav-curva);
}
.c-gaveta[data-aberta="false"] { border-radius: 16px; }
.c-gaveta__cabecalho {
width: 100%;
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
border: 0;
background: transparent;
color: var(--c-gav-tinta);
font: inherit;
text-align: left;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.c-gaveta__icone {
flex: none;
width: 48px;
height: 48px;
display: grid;
place-items: center;
border-radius: 12px;
background: var(--c-gav-superficie-2);
color: var(--c-gav-tinta-2);
}
.c-gaveta__icone svg { width: 22px; height: 22px; }
.c-gaveta__textos { flex: 1; min-width: 0; display: grid; }
.c-gaveta__titulo { font-size: 1rem; font-weight: 600; line-height: 1.3; }
/* fechada, o resumo aparece: até três linhas de 1,35 (em telas estreitas ele quebra em duas) */
.c-gaveta__resumo {
margin-top: 0;
max-height: 0;
opacity: 0;
overflow: hidden;
font-size: .875rem;
line-height: 1.35;
color: var(--c-gav-tinta-2);
transition: opacity .5s var(--c-gav-curva), max-height .5s var(--c-gav-curva), margin-top .5s var(--c-gav-curva);
}
.c-gaveta[data-aberta="false"] .c-gaveta__resumo { opacity: 1; max-height: 4.05em; margin-top: 2px; }
.c-gaveta__seta { flex: none; width: 20px; height: 20px; color: var(--c-gav-tinta-3); transform: rotate(0deg); transition: transform .5s var(--c-gav-curva); }
.c-gaveta[data-aberta="false"] .c-gaveta__seta { transform: rotate(180deg); }
.c-gaveta__corpo { display: grid; grid-template-rows: 1fr; opacity: 1; transition: grid-template-rows .5s var(--c-gav-curva), opacity .5s var(--c-gav-curva); }
.c-gaveta[data-aberta="false"] .c-gaveta__corpo { grid-template-rows: 0fr; opacity: 0; }
/* a visibilidade acompanha a altura: entra na hora ao abrir (atraso 0) e só sai no fim da animação
ao fechar (atraso .5s), porque a transição que vale é a do estado de DESTINO */
.c-gaveta__dentro { overflow: hidden; padding: 0 16px; transition: visibility 0s linear 0s; }
.c-gaveta[data-aberta="false"] .c-gaveta__dentro { visibility: hidden; transition-delay: .5s; }
.c-gaveta .c-tabela, .c-gaveta .c-lista-linhas { margin-bottom: 8px; }
/* as linhas entram escalonadas ao abrir (75 ms por linha, --i do JS); ao fechar, sem atraso — e o
atraso mora no estado de destino, que é o ABERTO. `transform` em <tr> não é confiável no Safari:
a opacidade vai na linha e o deslize nas células. */
.c-gaveta .c-tabela tr { opacity: 1; transition: opacity .5s var(--c-gav-curva), background-color .2s ease; transition-delay: calc(var(--i, 0) * 75ms); }
.c-gaveta .c-tabela th, .c-gaveta .c-tabela td { transform: none; transition: transform .5s var(--c-gav-curva); transition-delay: calc(var(--i, 0) * 75ms); }
.c-gaveta .c-lista-linhas li { opacity: 1; transform: none; transition: opacity .5s var(--c-gav-curva), transform .5s var(--c-gav-curva); transition-delay: calc(var(--i, 0) * 75ms); }
.c-gaveta[data-aberta="false"] .c-tabela tr, .c-gaveta[data-aberta="false"] .c-lista-linhas li { opacity: 0; transition-delay: 0s; }
.c-gaveta[data-aberta="false"] .c-tabela th, .c-gaveta[data-aberta="false"] .c-tabela td,
.c-gaveta[data-aberta="false"] .c-lista-linhas li { transform: translateY(16px); transition-delay: 0s; }
@media (prefers-reduced-motion: reduce) {
.c-gaveta, .c-gaveta * { transition: none !important; }
}
/* ===== gaveta.js =================================================================== */
/* GAV-01 · Gaveta — abre e fecha ao toque no cabeçalho; numera as linhas (--i) para a entrada
escalonada do CSS; estado em data-aberta e aria-expanded.
É o abrir(false) do fim do preparar() que FECHA a gaveta — o CSS a deixa aberta e o markup não
traz data-aberta. Se este arquivo não rodar, o conteúdo fica aberto e legível em vez de inacessível.
O aria-expanded="true" está no MARKUP, para o estado anunciado bater com a tela quando o JS não
roda; o abrir(false) o troca por "false" assim que ele roda. */
(function () {
'use strict';
function preparar(gaveta) {
var botao = gaveta.querySelector('.c-gaveta__cabecalho');
if (!botao) { return; }
Array.prototype.forEach.call(gaveta.querySelectorAll('.c-tabela tr, .c-lista-linhas li'), function (linha, i) {
linha.style.setProperty('--i', String(i));
});
function abrir(sim) {
gaveta.setAttribute('data-aberta', sim ? 'true' : 'false');
botao.setAttribute('aria-expanded', sim ? 'true' : 'false');
}
botao.addEventListener('click', function () { abrir(gaveta.getAttribute('data-aberta') !== 'true'); });
// quem fecha é esta linha, não o CSS — e o primeiro fechamento não anima
gaveta.classList.add('c-gaveta--sem-transicao');
abrir(false);
gaveta.getBoundingClientRect(); // força o recálculo já com o estado fechado, antes de a transição voltar
window.requestAnimationFrame(function () { gaveta.classList.remove('c-gaveta--sem-transicao'); });
}
function iniciar() { Array.prototype.forEach.call(document.querySelectorAll('.c-gaveta'), preparar); }
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', iniciar); } else { iniciar(); }
})();
/* ===== markup.html ================================================================= */
<!-- GAV-01 · Gaveta. Nasce ABERTA no markup e no CSS: é o JS que fecha. Se ele não rodar, o
conteúdo continua acessível. O aria-expanded="true" aqui é proposital, pelo mesmo motivo.
O conteúdo do corpo é livre — aqui vai a TAB-01, que é o par mais usado. -->
<div class="c-gaveta">
<button type="button" class="c-gaveta__cabecalho" aria-expanded="true" aria-controls="gaveta-espaco">
<span class="c-gaveta__icone" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M9 22V12h6v10"/></svg>
</span>
<span class="c-gaveta__textos">
<span class="c-gaveta__titulo">O espaço</span>
<span class="c-gaveta__resumo">30 m², pé-direito de 4 m, ciclorama branco de 5 m.</span>
</span>
<svg class="c-gaveta__seta" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 15l-6-6-6 6"/></svg>
</button>
<div class="c-gaveta__corpo" id="gaveta-espaco">
<div class="c-gaveta__dentro">
<table class="c-tabela">
<tbody>
<tr><th scope="row">Área</th><td>30 m²</td></tr>
<tr><th scope="row">Pé-direito</th><td>4 m</td></tr>
<tr><th scope="row">Capacidade</th><td>até 8 pessoas</td></tr>
</tbody>
</table>
</div>
</div>
</div>
/* ===== 09-gaveta-atividades.tsx ==================================================== */
"use client"
import type React from "react"
import { useState } from "react"
import { Bell, MessageCircle, Award, Calendar, Tag, CheckSquare, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
interface Activity {
id: number
icon: React.ReactNode
iconBg: string
title: string
description: string
time: string
}
const activities: Activity[] = [
{
id: 1,
icon: <MessageCircle className="h-4 w-4" />,
iconBg: "bg-neutral-700 dark:bg-neutral-700 bg-neutral-200",
title: "New Message!",
description: "Sarah sent you a message.",
time: "Just Now",
},
{
id: 2,
icon: <Award className="h-4 w-4" />,
iconBg: "bg-neutral-700 dark:bg-neutral-700 bg-neutral-200",
title: "Level Up!",
description: "You've unlocked a new achievement.",
time: "2 min ago",
},
{
id: 3,
icon: <Calendar className="h-4 w-4" />,
iconBg: "bg-neutral-700 dark:bg-neutral-700 bg-neutral-200",
title: "Reminder: Meeting Today",
description: "Your team meeting starts in 30 minutes.",
time: "3 hour ago",
},
{
id: 4,
icon: <Tag className="h-4 w-4" />,
iconBg: "bg-neutral-700 dark:bg-neutral-700 bg-neutral-200",
title: "Special Offer!",
description: "Save 20% off on subscription upgrade.",
time: "12 hours ago",
},
{
id: 5,
icon: <CheckSquare className="h-4 w-4" />,
iconBg: "bg-neutral-700 dark:bg-neutral-700 bg-neutral-200",
title: "Task Assigned!",
description: "A new task is awaiting your action.",
time: "Yesterday",
},
]
export function ActivityDropdown() {
const [isOpen, setIsOpen] = useState(false)
return (
<div
className={cn(
"w-full max-w-md rounded-2xl shadow-2xl overflow-hidden cursor-pointer select-none",
"bg-white dark:bg-neutral-900",
"shadow-xl shadow-black/10 dark:shadow-black/50",
"transition-all duration-500 ease-[cubic-bezier(0.4,0,0.2,1)]",
isOpen ? "rounded-3xl" : "rounded-2xl",
)}
onClick={() => setIsOpen(!isOpen)}
>
{/* Header */}
<div className="flex items-center gap-4 p-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-100 dark:bg-neutral-800 transition-colors duration-300">
<Bell className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
</div>
<div className="flex-1 overflow-hidden">
<h3 className="text-base font-semibold text-neutral-900 dark:text-white">5 New Activities</h3>
<p
className={cn(
"text-sm text-neutral-500 dark:text-neutral-400",
"transition-all duration-500 ease-[cubic-bezier(0.4,0,0.2,1)]",
isOpen ? "opacity-0 max-h-0 mt-0" : "opacity-100 max-h-6 mt-0.5",
)}
>
What's happening around you
</p>
</div>
<div className="flex h-8 w-8 items-center justify-center">
<ChevronUp
className={cn(
"h-5 w-5 text-neutral-400 transition-transform duration-500 ease-[cubic-bezier(0.4,0,0.2,1)]",
isOpen ? "rotate-0" : "rotate-180",
)}
/>
</div>
</div>
```
{/* Activity List */}
<div
className={cn(
"grid",
"transition-all duration-500 ease-[cubic-bezier(0.4,0,0.2,1)]",
isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",
)}
>
<div className="overflow-hidden">
<div className="px-2 pb-4">
<div className="space-y-1">
{activities.map((activity, index) => (
<div
key={activity.id}
className={cn(
"flex items-start gap-3 rounded-xl p-3",
"transition-all duration-500 ease-[cubic-bezier(0.4,0,0.2,1)]",
"hover:bg-neutral-100 dark:hover:bg-neutral-800/50",
isOpen ? "translate-y-0 opacity-100" : "translate-y-4 opacity-0",
)}
style={{
transitionDelay: isOpen ? `${index * 75}ms` : "0ms",
}}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-neutral-100 dark:bg-neutral-700 transition-colors duration-300">
<span className="text-neutral-600 dark:text-neutral-300">{activity.icon}</span>
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-semibold text-neutral-900 dark:text-white">{activity.title}</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 truncate">{activity.description}</p>
</div>
<span className="text-xs text-neutral-400 dark:text-neutral-500 shrink-0 pt-0.5">
{activity.time}
</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
```
)
}
Já rodou em
- trento-pedro · home v3 — seção “O espaço”: ficha e equipamentos engavetados (leva 2, 21/09/2026)
Exige
- react (useState)
- lucide-react (Bell, MessageCircle, Award, Calendar, Tag, CheckSquare, ChevronUp)
- cn
- Tailwind com dark:
Como se configura
--c-gav-superficie--c-gav-superficie-2--c-gav-linha--c-gav-tinta--c-gav-tinta-2--c-gav-tinta-3--c-gav-raio
A ficha
O que o 21st.dev entregou
Recebido em 21/09/2026 (noite), na leva 2 de correções, para "engavetar" a ficha do espaço e a lista de equipamentos da seção 3: "as linhas da tabela ou da lista se abrissem quando o usuário clicasse em um botão" — o intuito é tirar o destaque que as duas têm hoje na tela.
Como veio: colado no chat, com um artefato de cópia no meio — a cerca de código ( ` ) que abre antes do comentário {/ Activity List /} e fecha antes do ) final. O arquivo está salvo como veio, cerca inclusa; não é JSX válido assim, e a estrutura é recuperável (é o mesmo <div> continuando).
Dependências: React (useState), lucide-react (Bell, MessageCircle, Award, Calendar, Tag, CheckSquare, ChevronUp), cn do shadcn, Tailwind com dark:. "use client" é do Next.js.
Como funciona:
- Um cartão (
max-w-md,rounded-2xl, sombra grande, branco ouneutral-900no escuro) inteiro clicável (onClickno contêiner,select-none); aberto, o canto vai arounded-3xlem 0,5 s. - Cabeçalho: ícone num quadrado de 48 px (
rounded-xl, fundo apagado), títulotext-basesemibold, um resumo que some ao abrir (opacity-0 max-h-0), e umChevronUpque gira 180° fechado. - Lista:
display: gridcomgrid-template-rowsanimando 0fr → 1fr e opacidade 0 → 1 (0,5 s,cubic-bezier(.4, 0, .2, 1)); dentro,overflow-hidden. Cada item entra deslizando 16 px de baixo com fade, comtransitionDelaydeindex * 75ms— só na abertura (0 ms ao fechar). - Item: ícone de 40 px, título
text-smsemibold, descriçãotext-smapagada truncada, hora à direita; hover apagado.
O que serve à Trento, e o que muda na reescrita (leva 2):
- Entra o mecanismo inteiro — cabeçalho com ícone/título/resumo/seta, corpo em
grid-rows0fr → 1fr, linhas escalonadas a 75 ms —, reescrito em HTML/CSS/JS puro (gaveta.css+gaveta.js), comaria-expandedearia-controlsno botão (o original não tem). - O conteúdo não é uma lista de avisos: é a tabela da ficha (10 linhas
th/td) e a lista de equipamentos — os itens escalonados são as linhas delas, não itens novos. - O clique fica no botão do cabeçalho, não no cartão inteiro (o cartão contém uma tabela que se lê e se seleciona).
- Cores e raios pelos tokens da Trento: fundo
--tr-superficie, borda--tr-hairline, ícone em--tr-superficie-2, texto--tr-tinta/--tr-tinta-2; sem a sombra (shadow-2xlnão existe no design system) e semdark:. - Fechada por padrão em todas as larguras (decisão do Rafa, 21/09). No desktop o espalhamento da seção 3 continua, com a gaveta fechada no lugar da tabela.
O que a adaptação da OCTA mudou
aria-expandedearia-controlsno botão- O padrão do CSS é aberta, e quem fecha é o JS: se o script não chegar, a falha abre em vez de esconder o conteúdo
- O
overflow: hiddensaiu do cartão e foi para o corpo, para não recortar o anel de foco - O primeiro fechamento não anima (classe sem transição, recálculo forçado, classe retirada no quadro seguinte)
transformem<tr>não é confiável no Safari: a opacidade vai na linha e o deslize nas células
Lacunas do original
- Sem
aria-expanded/aria-controlsno cabeçalho overflow: hiddenno cartão recorta o anel de foco do cabeçalho