Pular para o conteúdo
Biblioteca GAV-01

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
abrir sozinho ↗
Código
GAV-01 · alias gaveta
Função
Gaveta e revelação
Fonte
21st.dev · dropdown de atividades
Recebido
21/09/2026
Status
adotado
Licença
não verificada

O código

gaveta.css + gaveta.js + markup.html 166 linhas · 3 arquivo(s) numa caixa só
/* ===== 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>

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 ou neutral-900 no escuro) inteiro clicável (onClick no contêiner, select-none); aberto, o canto vai a rounded-3xl em 0,5 s.
  • Cabeçalho: ícone num quadrado de 48 px (rounded-xl, fundo apagado), título text-base semibold, um resumo que some ao abrir (opacity-0 max-h-0), e um ChevronUp que gira 180° fechado.
  • Lista: display: grid com grid-template-rows animando 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, com transitionDelay de index * 75ms — só na abertura (0 ms ao fechar).
  • Item: ícone de 40 px, título text-sm semibold, descrição text-sm apagada 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-rows 0fr → 1fr, linhas escalonadas a 75 ms —, reescrito em HTML/CSS/JS puro (gaveta.css + gaveta.js), com aria-expanded e aria-controls no 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-2xl não existe no design system) e sem dark:.
  • 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-expanded e aria-controls no 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: hidden saiu 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)
  • transform em <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-controls no cabeçalho
  • overflow: hidden no cartão recorta o anel de foco do cabeçalho