← Biblioteca componentes/overlay/aviso-cookies/ficha.md CódigoTela cheia
overlay/aviso-cookies

Aviso de cookies com preferências granulares

🟢 estavel complexidade média prefixo lgpd- cookies lgpd consentimento privacidade banner dialog-nativo acessibilidade foco-preso localstorage fixed sem-lib escuro

Incluir · as linhas que se colam num site novo, depois do core (CLAUDE.md §10)

<link rel="stylesheet" href="componentes/overlay/aviso-cookies/estilo.css">
<script src="componentes/overlay/aviso-cookies/script.js"></script>

markup.html · 193 linhas · 11,0 KB

<!-- ==========================================================================
     AVISO DE COOKIES — barra de consentimento + painel de preferências
     Prefixo: lgpd-
     Requer: core (tokens, base) + estilo.css + script.js
     NÃO requer motion.css nem motion.js: a entrada da barra é transição
     própria, pelo motivo explicado abaixo.

     A IDEIA. Dois níveis. Embaixo, uma barra discreta que NÃO prende o foco
     e NÃO bloqueia a leitura, com os dois caminhos do primeiro nível —
     "Recusar não essenciais" e "Aceitar todos" — no MESMO peso visual, e um
     terceiro caminho para as preferências. Dentro, um <dialog> nativo com um
     interruptor por categoria e o que cada uma faz, em português de gente.

     A FORMA AQUI É REGRA, NÃO GOSTO. Três decisões que parecem estéticas e
     são jurídicas:

       1. OS DOIS BOTÕES LEEM A MESMA PRIMITIVA (.btn .btn-ghost). Trocar um
          deles por .btn-primary — deixar "aceitar" cheio de cor de marca e
          "recusar" em contorno — é o padrão escuro que a ANPD reprova:
          recusar tem de ser tão fácil quanto aceitar. Se o projeto quiser
          mais presença, sobem OS DOIS para .btn-primary, nunca um só.
       2. RECUSAR VIVE NO PRIMEIRO NÍVEL. Ele não está escondido dentro de
          "Preferências"; "Preferências" é um terceiro caminho, para quem
          quer granularidade, e não o único caminho para dizer não.
       3. A BARRA NÃO BLOQUEIA A PÁGINA. Sem scrim, sem trava de rolagem,
          sem foco preso. Forçar a escolha para poder ler é o outro padrão
          escuro. Quem bloqueia é só o painel, e só depois de a pessoa pedir.

     NADA NÃO ESSENCIAL DISPARA AQUI. Este componente não carrega script de
     terceiro, não cria <script> nenhum e não conhece nenhuma tag. Ele só
     grava a escolha e a PUBLICA em dois canais — o atributo data-lgpd-estado
     no <html> e um CustomEvent no document. Quem liga a tag lê o estado.
     A arquitetura desses dois canais está comentada no script.js.

     POR QUE role="region", E NÃO role="dialog". Um role="dialog" não-modal
     promete um limite de foco que esta barra deliberadamente não tem: ela
     não recebe foco na carga (roubar o foco de quem começou a ler é hostil)
     e não prende o Tab. Anunciar "diálogo" e não gerenciar foco nenhum é
     descrever errado o que está na tela. Uma REGIÃO rotulada é o que a barra
     de fato é: um marco de página, achável pela lista de regiões do leitor
     de tela, anunciado pelo próprio nome. O painel, esse sim, é <dialog> com
     showModal() — ali o foco preso é verdade.

     A ORDEM NO DOM É PROPOSITAL. O bloco vai logo depois de <body>, antes do
     cabeçalho do site: a barra desenha embaixo (position:fixed), mas é a
     PRIMEIRA parada do Tab. Uma escolha legal que a pessoa é obrigada a
     tomar não pode estar a trinta tabulações de distância.

     FORA DE TODO [data-rv]. A barra é position:fixed, e o §5 do contrato é
     categórico: elemento com filtro é bloco de contenção para descendente
     fixed — inclusive blur(0), que é onde o reveal do core termina. Dentro
     de um [data-rv] revelado, esta barra colaria no fundo da CAIXA do pai em
     vez de colar na janela, sem um erro sequer no console.

     SEM JAVASCRIPT NÃO HÁ BARRA, e isso é o certo. Ela nasce [hidden] e quem
     a mostra é o script. Uma barra visível cujos botões não gravam nada
     seria pior que nenhuma: pediria uma escolha que não seria registrada.
     O resto da página não depende de nada disto.
     ========================================================================== -->

<!-- data-lgpd-versao é a versão da política. Trocar este valor faz a barra
     voltar para quem já tinha escolhido — é assim que uma política nova pede
     o consentimento de novo. Use uma data ou um número que a área jurídica
     reconheça; o componente só compara o texto com o que está gravado. -->
<div class="lgpd sec-deep" data-lgpd data-lgpd-versao="2026-09-06">

  <!-- Anúncio de estado. Mora AQUI, fora da barra, e não é detalhe: assim
       que a escolha é feita a barra vira [hidden], e conteúdo escondido não
       é anunciado por ninguém. Leva tabindex="-1" porque é para cá que o
       foco vai quando a barra some debaixo dele — ver o script. -->
  <p class="lgpd-anuncio visually-hidden" data-lgpd-anuncio role="status" aria-live="polite" tabindex="-1"></p>

  <div class="lgpd-bar" data-lgpd-bar role="region" aria-label="Aviso de cookies" hidden>
    <div class="wrap lgpd-bar-wrap">

      <div class="lgpd-bar-texto">
        <p class="lgpd-bar-titulo">Cookies e dados de navegação</p>
        <p class="lgpd-bar-corpo">Os necessários mantêm a sessão de pé, protegem o formulário de contato e guardam esta escolha. Os de desempenho e os de marketing só são ligados se você permitir — até lá, nada dispara.
          <!-- href="#": troque pelo endereço real da política de privacidade
               antes de publicar. Sem a política no ar, a barra promete uma
               leitura que não existe. -->
          <a class="lgpd-link" href="#">Política de privacidade</a>.</p>
      </div>

      <div class="lgpd-bar-acoes">
        <!-- Os dois botões são IRMÃOS de classe E dividem uma grade de duas
             colunas iguais, de propósito: mesma caixa, mesmo desenho, mesmo
             corpo. Ver a decisão 1 no cabeçalho antes de mexer. -->
        <div class="lgpd-par">
          <button class="btn btn-ghost lgpd-acao" type="button" data-lgpd-recusar>Recusar não essenciais</button>
          <button class="btn btn-ghost lgpd-acao" type="button" data-lgpd-aceitar>Aceitar todos</button>
        </div>
        <button class="lgpd-preferencias" type="button" data-lgpd-abrir aria-haspopup="dialog">Preferências</button>
      </div>

    </div>
  </div>

  <!-- O painel é <dialog> + showModal(): daí vêm prontos o foco preso, o Esc
       que fecha, o foco devolvido a quem abriu, o ::backdrop e o resto da
       página inerte para tecnologia assistiva. Mesmo critério do
       midia/showreel-expansivel — usar o elemento nativo em vez de
       reimplementar quatro comportamentos à mão.

       aria-label em vez de aria-labelledby: rotular pelo id do <h2> obrigaria
       um id fixo, e id fixo é a única coisa deste bloco que colidiria se ele
       aparecesse duas vezes na mesma página.

       .dialog-casca é a primitiva do core (base.css): zera a caixa que o
       navegador dá ao <dialog> e pinta o ::backdrop. A classe do core vem
       ANTES da local. -->
  <dialog class="dialog-casca lgpd-painel sec-deep" data-lgpd-painel aria-label="Preferências de cookies">
    <div class="lgpd-quadro">

      <div class="lgpd-topo">
        <h2 class="lgpd-titulo">Preferências de cookies</h2>
        <!-- autofocus: sem ele o showModal() entrega o foco inicial ao primeiro
             focável que achar, e o .lgpd-quadro é um container ROLÁVEL — o
             Chrome o considera focável e o foco cai numa <div>, não num
             controle. Medido: sem autofocus, o foco ao abrir era div.lgpd-quadro;
             com ele, é este botão. -->
        <button class="lgpd-fechar" type="button" autofocus data-lgpd-fechar aria-label="Fechar as preferências de cookies">
          <svg viewBox="0 0 24 24" focusable="false" aria-hidden="true">
            <path d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor" stroke-linecap="round"/>
          </svg>
        </button>
      </div>

      <p class="lgpd-lead">Cada categoria abaixo é uma escolha separada. Enquanto você não salvar, só os necessários estão ativos.</p>

      <ul class="lgpd-lista">

        <!-- A categoria travada. `disabled` e não `aria-disabled`: aqui não há
             nada que possa mudar de estado nunca, e um controle assim é ruído
             na ordem de tabulação — diferente da seta de carrossel do core,
             que usa aria-disabled só para não jogar o foco fora da dobra. O
             que a pessoa precisa saber está escrito, não só implícito. -->
        <li class="lgpd-item">
          <label class="lgpd-cat is-fixa">
            <input class="lgpd-check visually-hidden" type="checkbox" data-lgpd-cat="necessarios" checked disabled>
            <span class="lgpd-switch" aria-hidden="true"></span>
            <span class="lgpd-corpo">
              <span class="lgpd-nome">Necessários <span class="lgpd-fixa">sempre ativos</span></span>
              <span class="lgpd-desc">Mantêm a sessão aberta, protegem o formulário de contato e guardam esta própria escolha. Sem eles o site não funciona — por isso não há como desligá-los.</span>
            </span>
          </label>
        </li>

        <li class="lgpd-item">
          <label class="lgpd-cat">
            <input class="lgpd-check visually-hidden" type="checkbox" data-lgpd-cat="desempenho">
            <span class="lgpd-switch" aria-hidden="true"></span>
            <span class="lgpd-corpo">
              <span class="lgpd-nome">Desempenho</span>
              <span class="lgpd-desc">Contam quantas pessoas abriram cada página e em que ponto a leitura para. Os números são agregados: servem para melhorar o material, não para identificar você.</span>
            </span>
          </label>
        </li>

        <li class="lgpd-item">
          <label class="lgpd-cat">
            <input class="lgpd-check visually-hidden" type="checkbox" data-lgpd-cat="marketing">
            <span class="lgpd-switch" aria-hidden="true"></span>
            <span class="lgpd-corpo">
              <span class="lgpd-nome">Marketing</span>
              <span class="lgpd-desc">Registram se você chegou aqui por um anúncio e se voltou depois. Servem para medir campanha, e podem ser lidos pela plataforma que a veicula.</span>
            </span>
          </label>
        </li>

      </ul>

      <div class="lgpd-painel-acoes">
        <!-- Recusar e aceitar continuam irmãos de classe também aqui: os dois
             caminhos do primeiro nível existem em TODO nível. "Salvar
             escolhas" é o primário porque é a ação do que a pessoa acabou de
             fazer nos interruptores — e é o caminho mais granular dos três,
             não o mais permissivo. -->
        <div class="lgpd-par">
          <button class="btn btn-ghost lgpd-acao" type="button" data-lgpd-recusar>Recusar não essenciais</button>
          <button class="btn btn-ghost lgpd-acao" type="button" data-lgpd-aceitar>Aceitar todos</button>
        </div>
        <button class="btn btn-primary lgpd-acao" type="button" data-lgpd-salvar>Salvar escolhas</button>
      </div>

      <p class="nota lgpd-nota">A escolha fica gravada neste navegador, com a data e a versão desta política — nada que identifique você. Quando a política mudar de versão, perguntamos de novo. Para rever depois, use o link <span class="lgpd-nota-forte">Preferências de cookies</span> no rodapé.
        <!-- href="#": mesmo destino real da política, ver acima. -->
        <a class="lgpd-link" href="#">Política de privacidade</a>.</p>

    </div>
  </dialog>

</div>

estilo.css · 409 linhas · 14,7 KB

/* ==========================================================================
   AVISO DE COOKIES — prefixo lgpd-
   Barra de consentimento colada na janela + painel de preferências em
   <dialog> nativo.

   Tokens usados:
     --bg --fg --fg-2 --fg-muted   escopo de tom (a raiz declara .sec-deep)
     --rule --rule-2               filete da barra, borda do interruptor
     --accent                      interruptor ligado, sublinhado, foco
     --dark                        base do véu do ::backdrop
     --veil                        véu do ::backdrop, servido pela primitiva
                                   .dialog-casca — este componente sobrescreve
                                   o grau, e diz por que ali embaixo
     --display --sans              faces
     --ease                        curva do projeto
     --radius-pill --radius-media  forma
     --max --pad                   só via a primitiva .wrap

   POR QUE A RAIZ DECLARA .sec-deep. Este bloco não mora dentro de nenhuma
   <section>: ele é filho direto do <body>, e portanto não há escopo de tom
   para herdar — --bg, --fg e --rule chegariam vazios. Declarar o escopo no
   markup do próprio bloco não é override (o §3 do manual descreve o padrão,
   com precedente em conteudo/bento-recursos-expansivel): o componente ENTRA
   num escopo que o core já define e continua lendo os mesmos nomes, então o
   tema segue mandando na cor. Para uma barra clara, troque a classe da raiz
   por .sec-paper — nenhuma regra deste arquivo muda.

   OS PARAFUSOS, todos na raiz e todos prefixados:
     --lgpd-z        camada da barra, e ela NÃO adota o --z-overlay (200) do
                     core de propósito. Aquele degrau é o de sobreposição de
                     página inteira — a que cobre tudo, trava a rolagem e
                     prende o foco. Esta barra não é uma delas: é uma faixa
                     que não bloqueia nada, e tem de ficar ABAIXO do que é
                     sobreposição de verdade, para que um drawer de menu
                     aberto a cubra em vez de disputar a mesma camada. Daí
                     150, entre a nav fixa do core (100) e o --z-overlay.
                     O painel, esse sim sobreposição, é <dialog> em top
                     layer: não participa de z-index nenhum, e portanto não
                     há onde o --z-overlay entrar nele.
     --lgpd-dur      duração da entrada da barra
     --lgpd-saida    duração da saída — bate com SAIDA no script.js
     --lgpd-atraso   atraso da entrada, para não competir com a carga
     --lgpd-larg     largura do painel
     --lgpd-veil-mix grau do véu do painel — ver .lgpd-painel
   ========================================================================== */

.lgpd{
  --lgpd-z:150;
  --lgpd-dur:.7s;
  --lgpd-saida:.32s;
  --lgpd-atraso:.45s;
  --lgpd-larg:660px;
  --lgpd-veil-mix:84%;
}

/* ===== A BARRA ===== */

.lgpd-bar{
  position:fixed;
  inset-inline:0;
  inset-block-end:0;
  z-index:var(--lgpd-z,150);
  background:var(--bg);
  border-block-start:1px solid var(--rule-2);
  /* Nada entra seco (§5): blur indo a zero, junto do fade e da subida.
     A transição é PRÓPRIA, e não [data-rv], por dois motivos que se somam:
     o reveal do core é observador de ENTRADA em viewport, e uma barra fixa
     já nasce em viewport — ele dispararia na carga, antes de o script saber
     se há escolha gravada; e o §5 proíbe elemento fixed sob um filtro de
     ancestral, o que este bloco resolve não tendo ancestral nenhum. */
  opacity:0;
  filter:blur(14px);
  transform:translateY(18px);
  transition:
    opacity var(--lgpd-saida) var(--ease),
    filter var(--lgpd-saida) var(--ease),
    transform var(--lgpd-saida) var(--ease);
}
/* Duração e atraso moram no estado de CHEGADA, não na regra base: a
   transition-delay lida é sempre a do estado para o qual se vai. Assim a
   entrada demora e espera; a saída, que é resposta a um clique, sai na hora
   e sem atraso nenhum. */
.lgpd-bar.is-in{
  opacity:1;
  filter:blur(0);
  transform:none;
  transition-duration:var(--lgpd-dur);
  transition-delay:var(--lgpd-atraso);
}
@media(prefers-reduced-motion:reduce){
  /* Quem pediu menos movimento recebe a MESMA barra, sem a entrada. Ela não
     deixa de aparecer: só aparece pronta. */
  .lgpd-bar{opacity:1;filter:none;transform:none;transition:none}
  .lgpd-bar.is-in{transition:none}
}
/* §6.5 do contrato. Hoje .lgpd-bar não declara `display`, e o [hidden] do
   agente do usuário bastaria — mas basta alguém acrescentar um
   `display:flex` aqui para o atributo virar decoração e a barra continuar na
   tela depois da escolha, sem erro nenhum. A linha custa nada e fecha a
   armadilha antes de ela existir. */
.lgpd-bar[hidden]{display:none}

.lgpd-bar-wrap{
  display:flex;
  flex-wrap:wrap;
  align-items:center;
  gap:clamp(16px,2.4vw,40px);
  padding-block:clamp(16px,2vw,22px);
}

/* 420px é o ponto em que o parágrafo para de valer a pena: abaixo disso ele
   quebra em cinco linhas e a barra come meia tela. É a largura que decide
   sozinha se as ações caem para a segunda fila, sem media query. */
.lgpd-bar-texto{flex:1 1 420px;min-width:0}

.lgpd-bar-titulo{
  font-family:var(--display);
  font-weight:700;
  font-size:14px;
  letter-spacing:-.01em;
  color:var(--fg);
  margin-bottom:4px;
}

.lgpd-bar-corpo{
  font-size:13.5px;
  line-height:1.55;
  color:var(--fg-2);
  max-width:74ch;
}

.lgpd-bar-acoes{
  display:flex;
  flex-wrap:wrap;
  align-items:center;
  gap:12px;
  flex:0 0 auto;
}

/* A pílula do core em corpo de barra: menos padding, sem encolher a fonte —
   11.5px em caixa alta já é o piso legível. */
.lgpd-acao{padding:13px 24px}

/* O PAR DO PRIMEIRO NÍVEL, e esta grade é o item mais importante do arquivo.
   Duas colunas 1fr numa caixa de largura automática: as duas colunas ficam do
   tamanho da MAIOR, e não do próprio texto. Sem ela, "Aceitar todos" (13
   caracteres) sairia com 158px contra os 230px de "Recusar não essenciais" —
   mesmo desenho, caixa menor, e a mais curta lida como a secundária. Medido a
   1440 depois da grade: 230x43 as duas.
   Vale para a barra e para o painel: os dois caminhos do primeiro nível
   existem nos dois lugares, sempre em par. */
.lgpd-par{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}

/* O terceiro caminho. Botão de texto, e não .btn: se ele virasse pílula
   seriam TRÊS pílulas iguais na barra, e a hierarquia entre "as duas
   escolhas do primeiro nível" e "o caminho granular" desapareceria. O
   sublinhado é o que o mantém visível como ação — cor sozinha não basta. */
.lgpd-preferencias{
  background:none;
  border:0;
  padding:6px 2px;
  font-family:var(--sans);
  font-size:12.5px;
  letter-spacing:.02em;
  color:var(--fg-2);
  text-decoration:underline;
  text-underline-offset:4px;
  text-decoration-color:var(--rule-2);
  transition:color .3s var(--ease),text-decoration-color .3s var(--ease);
}
.lgpd-preferencias:hover{color:var(--accent);text-decoration-color:var(--accent)}
@media(prefers-reduced-motion:reduce){
  .lgpd-preferencias{transition:none}
}

.lgpd-link{
  color:var(--fg);
  text-decoration:underline;
  text-underline-offset:3px;
  text-decoration-color:var(--rule-2);
}
.lgpd-link:hover{color:var(--accent);text-decoration-color:var(--accent)}

/* ===== O PAINEL ===== */

/* A casca do <dialog> é a primitiva .dialog-casca do core, declarada ANTES
   desta classe no markup: dela vêm o padding, a borda, o fundo, os limites
   de tamanho que o navegador dá de graça — e o véu do ::backdrop. Sobra aqui
   o que é desta peça: a largura, o teto de altura e o grau do véu.

   O <dialog> nasce display:none na folha do agente do usuário e só é exibido
   com [open]. Escrever `.lgpd-painel{display:block}` sem o [open] deixaria o
   painel visível o tempo todo — é a armadilha do §6.5 na versão do elemento
   nativo. A primitiva não cobre este display de propósito: cada diálogo da
   casa entra de um jeito diferente. */
.lgpd-painel{
  inline-size:min(var(--lgpd-larg,660px),calc(100vw - 32px));
  /* Anda em par com o mesmo teto no .lgpd-quadro, que é quem rola. */
  max-block-size:min(86vh,820px);
  /* Véu MAIS ESCURO que os 78% da casa, e isto é desenho, não descuido: o
     painel cobre uma barra que já está na tela, e o véu tem de separar as
     duas camadas de forma inequívoca — a barra não pode continuar lendo como
     parte do que está por cima dela. Medido sobre uma dobra .sec-paper por
     trás, o composto vai de rgb(64,64,64) a 78% para rgb(49,49,49) a 84%:
     quase 7 pontos de L* em CIELAB, várias vezes o limiar do perceptível,
     então padronizar em 78% seria mudança visual e não refactor. Sobre dobra
     escura os dois graus dão praticamente o mesmo pixel — o grau do véu só
     se vê quando há claro atrás.
     O ::backdrop herda custom property do elemento que o originou: é por
     isso que declarar --veil AQUI basta para a primitiva pintar o véu certo. */
  --veil:color-mix(in srgb,var(--dark) var(--lgpd-veil-mix,84%),transparent);
}
.lgpd-painel[open]{display:block}

.lgpd-quadro{
  background:var(--bg);
  border:1px solid var(--rule-2);
  border-radius:var(--radius-media);
  padding:clamp(22px,3.4vw,36px);
  /* O quadro é quem rola, não o diálogo: com a rolagem no <dialog> a borda
     e o raio ficariam fora da área rolável e a quina cortaria o conteúdo. */
  max-block-size:min(86vh,820px);
  overflow-y:auto;
  overscroll-behavior:contain;
}

.lgpd-topo{
  display:flex;
  align-items:flex-start;
  justify-content:space-between;
  gap:16px;
}

.lgpd-titulo{
  font-family:var(--display);
  font-weight:700;
  font-size:clamp(19px,2.2vw,23px);
  letter-spacing:-.018em;
  line-height:1.2;
  color:var(--fg);
}

/* Não é .btn-disc do core: aquele é o disco de contorno de carrossel, com
   42px e borda. Aqui o X é o fechar de uma sobreposição — sem contorno, para
   não competir com os três botões de ação logo abaixo. */
.lgpd-fechar{
  flex:0 0 auto;
  display:grid;
  place-items:center;
  inline-size:34px;
  block-size:34px;
  margin:-6px -6px 0 0;
  padding:0;
  background:none;
  border:0;
  border-radius:var(--radius-pill);
  color:var(--fg-muted);
  transition:color .3s var(--ease);
}
.lgpd-fechar:hover{color:var(--accent)}
@media(prefers-reduced-motion:reduce){
  .lgpd-fechar{transition:none}
}
.lgpd-fechar svg{inline-size:17px;block-size:17px;stroke-width:1.6}

.lgpd-lead{
  font-size:14px;
  line-height:1.6;
  color:var(--fg-2);
  max-width:56ch;
  margin-top:10px;
}

.lgpd-lista{margin-top:22px}

.lgpd-item{border-top:1px solid var(--rule)}
.lgpd-item:first-child{border-top:0}

.lgpd-cat{
  /* position:relative não é enfeite: o <input> real é .visually-hidden, e
     .visually-hidden é position:absolute. Sem um ancestral posicionado AQUI,
     ele se ancora no <dialog> inteiro — e aí o Tab que chega no interruptor
     da última categoria rola o painel de volta ao topo, porque é para lá que
     o navegador acha que o foco foi. */
  position:relative;
  display:flex;
  align-items:flex-start;
  gap:16px;
  padding:18px 0;
  cursor:pointer;
}
/* A categoria travada não é clicável, e o cursor tem de dizer isso antes do
   clique — o `disabled` do input já impede a troca, mas em silêncio. */
.lgpd-cat.is-fixa{cursor:default}

/* O interruptor.
   O <input> real leva a primitiva .visually-hidden do core: fora da tela,
   inteiro para o teclado e para o leitor de tela — é o uso 2 documentado
   nela. O desenho fica no irmão .lgpd-switch, e não em ::after do próprio
   input, porque pseudo-elemento em controle de formulário depende de
   `appearance:none` e ainda assim varia entre motores. Com um irmão de
   verdade, o que se desenha é uma caixa comum. */
.lgpd-switch{
  position:relative;
  flex:0 0 auto;
  inline-size:46px;
  block-size:26px;
  margin-block-start:2px;
  border:1px solid var(--rule-2);
  border-radius:var(--radius-pill);
  background:none;
  transition:background-color .3s var(--ease),border-color .3s var(--ease);
}
.lgpd-switch::after{
  content:"";
  position:absolute;
  inset-block-start:3px;
  inset-inline-start:3px;
  inline-size:18px;
  block-size:18px;
  border-radius:var(--radius-pill);
  background:var(--fg-muted);
  transition:transform .3s var(--ease),background-color .3s var(--ease);
}
.lgpd-check:checked + .lgpd-switch{background:var(--accent);border-color:var(--accent)}
.lgpd-check:checked + .lgpd-switch::after{transform:translateX(20px);background:var(--bg)}
/* O foco é do input, mas o anel tem de desenhar em volta do que se vê. */
.lgpd-check:focus-visible + .lgpd-switch{outline:2px solid var(--accent);outline-offset:3px}
.lgpd-check:disabled + .lgpd-switch{opacity:.45}
@media(prefers-reduced-motion:reduce){
  .lgpd-switch,
  .lgpd-switch::after{transition:none}
}

.lgpd-corpo{display:block;min-width:0}

.lgpd-nome{
  display:block;
  font-family:var(--display);
  font-weight:700;
  font-size:14.5px;
  letter-spacing:-.005em;
  color:var(--fg);
}

.lgpd-fixa{
  font-family:var(--sans);
  font-weight:400;
  font-size:11px;
  letter-spacing:.14em;
  text-transform:uppercase;
  color:var(--fg-muted);
  margin-inline-start:8px;
}

.lgpd-desc{
  display:block;
  font-size:13px;
  line-height:1.6;
  color:var(--fg-2);
  max-width:62ch;
  margin-top:5px;
}

.lgpd-painel-acoes{
  display:flex;
  flex-wrap:wrap;
  gap:10px;
  margin-top:24px;
  padding-top:22px;
  border-top:1px solid var(--rule);
}

/* Tipografia pela primitiva .nota do core; margem e medida ficam aqui — 64ch
   porque a nota mora dentro do painel de preferências, não na coluna da
   página. */
.lgpd-nota{
  --nota-max:64ch;
  margin-top:18px;
}
.lgpd-nota-forte{color:var(--fg-2)}

/* ===== Telas estreitas =====
   Media query de largura no fim do bloco, como manda o §6.6. */

@media(max-width:720px){
  /* Os botões ocupam a linha inteira e ficam empilhados: lado a lado em
     390px, "Recusar não essenciais" não cabe sem quebrar em duas linhas
     dentro da pílula. Empilhados, os dois têm exatamente a mesma caixa —
     que é o ponto inegociável desta peça. */
  .lgpd-bar-acoes{width:100%;flex-direction:column;align-items:stretch;gap:12px}
  .lgpd-par{grid-template-columns:1fr}
  .lgpd-preferencias{align-self:flex-start}
  .lgpd-bar-wrap{gap:16px}
  .lgpd-bar-corpo{font-size:13px}
  .lgpd-painel-acoes{flex-direction:column;align-items:stretch}
}

@media(max-width:480px){
  /* O painel encosta nas bordas: 660px de largura pedida contra 390px de
     tela deixaria 16px de cada lado, e a lista de categorias fica apertada
     demais para o interruptor caber ao lado do texto. */
  .lgpd-painel{inline-size:calc(100vw - 20px);max-block-size:92vh}
  .lgpd-quadro{max-block-size:92vh}
  .lgpd-cat{gap:12px}
  .lgpd-switch{inline-size:42px;block-size:24px}
  .lgpd-switch::after{inline-size:16px;block-size:16px}
  .lgpd-check:checked + .lgpd-switch::after{transform:translateX(18px)}
}

script.js · 313 linhas · 12,8 KB

/* ==========================================================================
   AVISO DE COOKIES — prefixo lgpd-
   Mostra a barra quando não há escolha válida gravada, grava a escolha, e
   PUBLICA o estado para quem liga tag.

   O QUE ESTE SCRIPT NÃO FAZ, e não é esquecimento: ele não carrega nada de
   terceiro, não injeta <script>, não conhece nenhuma ferramenta de medição e
   não dispara evento de analytics. Nada não essencial pode rodar antes da
   escolha, e a única forma honesta de garantir isso é o componente não ter
   como. Quem liga a tag é o consumidor, lendo o estado.

   ------------------------------------------------------------------------
   COMO O ESTADO SAI DAQUI — a decisão de arquitetura desta peça

   O §7 do contrato proíbe qualquer coisa no escopo global, então não existe
   `window.lgpd.consentiu()`. Restam dois canais, e os DOIS são necessários,
   por motivos diferentes:

     1. ATRIBUTO — data-lgpd-estado no <html>. É a lista das categorias
        ligadas, separada por espaço ("necessarios desempenho"), ou a palavra
        "pendente" enquanto não houver escolha. É SÍNCRONO e PERMANENTE: quem
        carregar depois deste script — um bloco do Elementor, uma tag que só
        chega no fim, um script que o consumidor cola amanhã — lê o estado
        pronto, sem ter perdido nada. O separador por espaço não é capricho:
        é o que faz o seletor de atributo `~=` funcionar, no CSS e no JS.

            if (document.documentElement.matches('[data-lgpd-estado~="desempenho"]')) { ... }

     2. EVENTO — CustomEvent 'lgpd:consentimento' no document, com as
        categorias no detail. É para REAGIR À MUDANÇA sem recarregar: alguém
        que abriu as preferências no rodapé e desligou o marketing precisa ser
        avisado agora, e o atributo sozinho não avisa ninguém.

   Um canal só não resolve: o atributo não notifica, e o evento não tem
   memória — um ouvinte registrado depois do disparo nunca saberia. A receita
   para o consumidor é sempre a mesma: LEIA o atributo na carga, ESCUTE o
   evento para o que vier depois. Está na ficha, com exemplo.

   O evento também dispara na CARGA (origem 'gravado' ou 'pendente'), e não
   só na troca. É o que permite ao consumidor ter um caminho de código só.
   ------------------------------------------------------------------------

   O QUE FICA GRAVADO, e por que aqui localStorage é legítimo. O §4 do brief
   da casa proíbe localStorage para dado de pessoa e abre exatamente uma
   exceção: consentimento, onde gravar a escolha É a função da peça — uma
   escolha que não sobrevive ao recarregamento não é consentimento, é uma
   pergunta repetida para sempre. O registro é

       { "versao": "2026-09-06", "data": "<ISO>", "categorias": {...} }

   e nada nele identifica ninguém: não há id, não há e-mail, não há
   impressão digital de navegador. A VERSÃO é o que torna o consentimento
   revogável pelo lado de cá: política nova, versão nova, e a barra volta
   para todo mundo. A DATA é o que a área jurídica pede para provar quando a
   escolha foi dada.

   MOVIMENTO REDUZIDO: a barra aparece do mesmo jeito e no mesmo lugar — só
   sem a entrada com blur. Nada aqui anda sozinho, então não há mais nada a
   desligar.
   ========================================================================== */

(function () {

  /* Busca no documento, e este é o único componente da casa em que isso é o
     certo: consentimento é por DOCUMENTO, não por bloco. Duas barras na
     mesma página seriam duas respostas para a mesma pergunta, e a segunda
     sobrescreveria a primeira no mesmo endereço de localStorage. O guard
     abaixo é a saída silenciosa de sempre. */
  var raiz = document.querySelector('[data-lgpd]');
  if (!raiz) return;

  var barra   = raiz.querySelector('[data-lgpd-bar]');
  var painel  = raiz.querySelector('[data-lgpd-painel]');
  var anuncio = raiz.querySelector('[data-lgpd-anuncio]');
  if (!barra || !painel) return;

  var CHAVE    = 'lgpd-consentimento';
  var EVENTO   = 'lgpd:consentimento';
  var ATRIBUTO = 'data-lgpd-estado';
  /* Bate com --lgpd-saida no estilo.css. Mudar um pede mudar o outro: é o
     tempo entre tirar o .is-in e pôr o [hidden], para a barra sair desfocando
     em vez de piscar. */
  var SAIDA = 320;

  var versao = raiz.getAttribute('data-lgpd-versao') || '';

  /* As categorias saem do DOM, nunca de uma lista aqui dentro. Acrescentar
     uma categoria é acrescentar um <li> no markup — o script, o registro
     gravado e o evento acompanham sozinhos. */
  var campos = [];
  (function () {
    var achados = raiz.querySelectorAll('input[data-lgpd-cat]'), i;
    for (i = 0; i < achados.length; i++) campos.push(achados[i]);
  })();
  if (!campos.length) return;

  function reduzido() {
    if (window.LIB && typeof window.LIB.reduce === 'boolean') return window.LIB.reduce;
    return matchMedia('(prefers-reduced-motion: reduce)').matches &&
           location.search.indexOf('motion=on') < 0;
  }

  function nome(campo) { return campo.getAttribute('data-lgpd-cat'); }

  /* Um mapa de categorias com o mesmo valor em todas — menos as travadas,
     que são as necessárias e valem sempre true. É o que "Recusar não
     essenciais" (false) e "Aceitar todos" (true) gravam. */
  function mapa(valor) {
    var o = {}, i;
    for (i = 0; i < campos.length; i++) o[nome(campos[i])] = campos[i].disabled ? true : !!valor;
    return o;
  }

  /* O mapa do que está marcado nos interruptores agora. */
  function escolhido() {
    var o = {}, i;
    for (i = 0; i < campos.length; i++) o[nome(campos[i])] = campos[i].disabled ? true : !!campos[i].checked;
    return o;
  }

  function refletir(cats) {
    var i, c;
    for (i = 0; i < campos.length; i++) {
      c = campos[i];
      if (!c.disabled) c.checked = !!(cats && cats[nome(c)]);
    }
  }

  /* ---------- o registro gravado ---------- */

  function ler() {
    var cru, d;
    try {
      cru = localStorage.getItem(CHAVE);
    } catch (e) {
      /* Navegador com armazenamento bloqueado (janela privada de alguns
         motores, política de empresa). Sem registro que se possa ler, o
         estado é "pendente" e a barra aparece — que é o lado seguro do erro:
         perguntar de novo, nunca assumir consentimento. */
      return null;
    }
    if (!cru) return null;
    try { d = JSON.parse(cru); } catch (e2) { return null; }
    if (!d || typeof d !== 'object' || !d.categorias) return null;
    /* Política nova pede escolha nova. E a escolha antiga NÃO é reaproveitada
       como valor inicial dos interruptores: se o texto do que se consente
       mudou, marcar de antemão o que a pessoa disse sobre o texto anterior é
       responder por ela. */
    if (d.versao !== versao) return null;
    return d;
  }

  function gravar(cats) {
    var reg = { versao: versao, data: new Date().toISOString(), categorias: cats };
    try {
      localStorage.setItem(CHAVE, JSON.stringify(reg));
    } catch (e) {
      /* Cota cheia ou armazenamento bloqueado. A escolha vale para esta
         visita — o atributo e o evento saem do mesmo jeito — e a barra volta
         na próxima. Engolir o erro é deliberado: um console vermelho em cima
         de quem só clicou "recusar" não ajuda ninguém. */
    }
    return reg;
  }

  /* ---------- publicação do estado ---------- */

  function publicar(reg, origem) {
    var cats = reg ? reg.categorias : mapa(false);
    var ligadas = [], i, n;

    for (i = 0; i < campos.length; i++) {
      n = nome(campos[i]);
      if (cats[n]) ligadas.push(n);
    }

    document.documentElement.setAttribute(ATRIBUTO, reg ? ligadas.join(' ') : 'pendente');

    document.dispatchEvent(new CustomEvent(EVENTO, {
      detail: {
        estado: reg ? 'definido' : 'pendente',
        categorias: cats,
        versao: versao,
        data: reg ? reg.data : null,
        origem: origem
      }
    }));
  }

  function anunciar(texto) {
    if (anuncio) anuncio.textContent = texto;
  }

  /* ---------- a barra ---------- */

  function mostrarBarra() {
    barra.hidden = false;
    if (reduzido()) { barra.classList.add('is-in'); return; }
    /* Dois quadros, não um: com um só, o navegador às vezes agrupa a saída do
       [hidden] com a entrada do .is-in e a transição não chega a existir — a
       barra aparece pronta em vez de entrar. */
    requestAnimationFrame(function () {
      requestAnimationFrame(function () { barra.classList.add('is-in'); });
    });
  }

  function ocultarBarra() {
    if (barra.hidden) return;

    /* O foco tem de sair ANTES do elemento sumir debaixo dele. Quem clicou
       "Aceitar todos" pelo teclado está com o foco num botão que vai virar
       [hidden]; sem isto, o foco cai no <body> e a próxima tabulação recomeça
       do topo da página. O destino é a região de anúncio, que fica no mesmo
       ponto do DOM em que a barra estava: o Tab seguinte continua de onde
       parou, e o leitor de tela ouve a confirmação.
       preventScroll porque a região mora no início do <body>: sem ele, o
       navegador rolaria a página inteira para o topo por causa de um foco que
       ninguém vê. */
    if (barra.contains(document.activeElement) && anuncio) {
      anuncio.focus({ preventScroll: true });
    }

    barra.classList.remove('is-in');
    window.setTimeout(function () {
      if (!barra.classList.contains('is-in')) barra.hidden = true;
    }, reduzido() ? 0 : SAIDA);
  }

  /* ---------- o painel ---------- */

  function abrirPainel() {
    var reg = ler();
    /* Os interruptores partem sempre do que está GRAVADO — nunca do que
       ficou na tela de uma abertura anterior fechada no Esc. Fechar sem
       salvar não é escolha, e não pode virar meia escolha na próxima vez. */
    refletir(reg ? reg.categorias : mapa(false));

    if (painel.showModal) painel.showModal();
    /* Navegador sem <dialog>: abre não-modal, sem ::backdrop e sem foco
       preso. Perde a coreografia, mantém a função — é melhor que um botão
       morto. Em 2026 isto praticamente não acontece; a linha é o seguro. */
    else painel.setAttribute('open', '');
  }

  function fecharPainel() {
    if (painel.close && painel.open) painel.close();
    else painel.removeAttribute('open');
  }

  /* ---------- a escolha ---------- */

  function concluir(cats, origem) {
    var reg = gravar(cats);
    refletir(reg.categorias);
    publicar(reg, origem);
    /* Fecha ANTES de ocultar a barra: o <dialog> devolve o foco a quem abriu,
       e quem abriu costuma ser o botão "Preferências" da própria barra. Só
       depois de o foco voltar para lá é que faz sentido tirar a barra e
       redirecionar o foco. Invertendo a ordem, o dialog devolveria o foco a
       um elemento já escondido. */
    fecharPainel();
    anunciar('Preferências de cookies salvas.');
    ocultarBarra();
  }

  function ligar(seletor, fn) {
    var achados = raiz.querySelectorAll(seletor), i;
    for (i = 0; i < achados.length; i++) achados[i].addEventListener('click', fn);
  }

  /* Os mesmos dois caminhos existem na barra e no painel: um seletor pega os
     dois lugares, e não há como um deles ficar para trás numa reescrita. */
  ligar('[data-lgpd-aceitar]', function () { concluir(mapa(true), 'aceitar'); });
  ligar('[data-lgpd-recusar]', function () { concluir(mapa(false), 'recusar'); });
  ligar('[data-lgpd-salvar]',  function () { concluir(escolhido(), 'salvar'); });
  ligar('[data-lgpd-abrir]',   abrirPainel);
  ligar('[data-lgpd-fechar]',  fecharPainel);

  /* Clique no fundo fecha, como no midia/showreel-expansivel. O alvo ser o
     próprio <dialog> significa que o clique caiu fora do quadro — dentro, o
     alvo seria o quadro ou algo dentro dele. Fechar assim NÃO grava nada:
     sair sem escolher não é escolher. */
  painel.addEventListener('click', function (e) {
    if (e.target === painel) fecharPainel();
  });

  /* O ÚNICO listener no document, e ele é assim de propósito: o gancho
     [data-lgpd-reabrir] mora no RODAPÉ do site, fora deste bloco — é o link
     "Preferências de cookies" que torna o consentimento revogável. Por
     delegação, ele funciona esteja o rodapé onde estiver, e continua
     funcionando se o rodapé for injetado depois (Elementor, include de
     template). */
  document.addEventListener('click', function (e) {
    var alvo = e.target && e.target.closest ? e.target.closest('[data-lgpd-reabrir]') : null;
    if (!alvo) return;
    /* O gancho costuma ser um <a href="#"> por ser link de rodapé: sem isto o
       navegador salta para o topo da página ao abrir o painel. */
    e.preventDefault();
    abrirPainel();
  });

  /* ---------- partida ---------- */

  var gravado = ler();
  if (gravado) {
    refletir(gravado.categorias);
    publicar(gravado, 'gravado');
  } else {
    publicar(null, 'pendente');
    mostrarBarra();
  }

})();

Uma faixa discreta colada no fundo da janela: título curto, três linhas dizendo o que de fato acontece, e dois botões — Recusar não essenciais e Aceitar todos — desenhados na mesma primitiva, do mesmo tamanho, lado a lado. Ao lado deles, um terceiro caminho, Preferências, que abre um <dialog> nativo com um interruptor por categoria e uma frase explicando o que cada uma faz. A barra não escurece a página, não trava a rolagem e não prende o foco; o painel, esse sim, é modal.

O que resolve editorialmente: todo site que a biblioteca gerar precisa de um, e sem um componente catalogado ele seria improvisado em cada projeto — que é exatamente como se escreve um banner errado. Aqui a forma carrega a regra: o desenho não é só bonito, ele é o que separa um consentimento válido de um padrão escuro.

Qual irmão ele substitui: nenhum. É o primeiro componente de consentimento da casa, e nada no catálogo resolvia a interação — overlay/card-expansivel-fullscreen e navegacao/drawer-fullscreen são sobreposições de conteúdo, com foco preso e página travada, e usar qualquer um dos dois para pedir consentimento produziria justamente o bloqueio de leitura que a peça precisa evitar. navegacao/botao-flutuante é o outro elemento colado à janela, mas é um atalho de contato, não uma escolha.


Quando usar

Quando não usar


Padrão editorial

Slot Regra
Título da barra (.lgpd-bar-titulo) 3 a 5 palavras, descritivo e sem simpatia forçada: "Cookies e dados de navegação". Nada de "Sua privacidade importa" — quem escreve isso costuma estar prestes a pedir tudo.
Corpo da barra (.lgpd-bar-corpo) 30 a 55 palavras. Diga o que acontece, não o benefício: "os de desempenho e os de marketing só são ligados se você permitir — até lá, nada dispara". Proibido "para melhorar sua experiência": não é informação, é enchimento.
Link da política Sempre presente, na barra e no painel, sempre com o texto "Política de privacidade". Nunca escondido só dentro do painel.
Texto dos botões "Recusar não essenciais" e "Aceitar todos". Verbos simétricos, comprimento comparável, nenhum dos dois em diminutivo ou hesitação ("agora não", "talvez depois"). O terceiro é "Preferências", e não "Gerenciar cookies" — quem gerencia é o site, quem escolhe é a pessoa.
Nome da categoria (.lgpd-nome) 1 palavra: Necessários, Desempenho, Marketing. A travada leva o rótulo "sempre ativos" ao lado.
Descrição da categoria (.lgpd-desc) 20 a 35 palavras, em português de gente e no presente: "Contam quantas pessoas abriram cada página e em que ponto a leitura para". Diga também o limite: "os números são agregados; não identificam você".
Nota do painel (.lgpd-nota) O que fica gravado, onde, por quanto tempo e como trocar. É o parágrafo que transforma o banner em transparência de verdade.

Duas a quatro categorias. Com uma só não há o que escolher e o painel é teatro. Acima de quatro, a lista vira formulário e a taxa de leitura cai — se o site precisa de seis, o problema é o número de terceiros que ele carrega, não o desenho do painel.

Toda categoria da lista tem de existir de verdade. Uma categoria "Marketing" num site que não veicula anúncio nenhum é declaração falsa, e não custa nada apagar o <li>.

Especificação dos assets

Nenhum. A peça não usa imagem, ícone de terceiro nem fonte própria — o único SVG é o X de fechar, desenhado em currentColor no próprio markup.

O que ela exige que exista fora dela:

Política de privacidade página publicada, com endereço estável. Os dois href="#" do markup são placeholders e estão marcados com comentário HTML
Versão da política o texto de data-lgpd-versao. Use o identificador que a área jurídica reconhece no documento — é ele que aparece no registro de consentimento quando alguém pedir prova
Um lugar no rodapé para o link [data-lgpd-reabrir]. Sem ele, o consentimento deixa de ser revogável, e aí a peça inteira perde a validade

Compliance

Esta peça é um instrumento jurídico com aparência de componente, e o que ela tem de errado não aparece no navegador. Antes de publicar, o consumidor tem de:

  1. Trocar os dois href="#" pelo endereço real da política de privacidade.
  2. Ligar as tags ao estado — e ligar de verdade, lendo data-lgpd-estado / o evento (ver "Como usar"). Um banner bonito com o Google Analytics carregando no <head> de qualquer jeito é pior que nenhum banner: registra um consentimento que o site não respeita.
  3. Conferir a lista de categorias contra o que o site realmente carrega. Cada categoria declarada tem de corresponder a scripts que existem; cada script de terceiro tem de cair em alguma categoria.
  4. Preencher a política de privacidade com a mesma lista, nos mesmos nomes. Banner e política que discordam é a divergência que uma fiscalização acha primeiro.
  5. Levar o texto à área de compliance do cliente antes de publicar.

O que a peça já garante, e não deve ser desfeito:

Dado pessoal: o registro gravado é { versao, data, categorias } e não identifica ninguém — não há id, e-mail, IP nem impressão digital de navegador. Esta é a única exceção da biblioteca ao "nada de localStorage para dado de pessoa" (§4 do brief da casa), e ela existe porque uma escolha que não sobrevive ao recarregamento não é consentimento: é a mesma pergunta para sempre.


Como usar

<link rel="stylesheet" href="componentes/overlay/aviso-cookies/estilo.css">
<script src="componentes/overlay/aviso-cookies/script.js"></script>

Cole markup.html logo depois de <body>, antes do cabeçalho do site. A barra desenha embaixo de qualquer jeito (position:fixed), mas a posição no DOM decide a ordem do Tab — medido: com o bloco no começo, a primeira parada do teclado é a própria barra.

Depende de core/tokens.css e core/base.css, e de mais nada: sem motion.css, sem motion.js, sem lib. O script consulta LIB.reduce quando o core está na página e lê a preferência na mão quando não está.

E ponha o caminho de volta no rodapé:

<a href="#" data-lgpd-reabrir>Preferências de cookies</a>

Como ligar as tags ao estado

O estado sai daqui por dois canais, e os dois são necessários. O §7 do contrato proíbe qualquer coisa no escopo global, então não existe window.lgpd.consentiu().

Canal O quê Para quê
data-lgpd-estado no <html> lista de categorias ligadas separada por espaço ("necessarios desempenho"), ou "pendente" é o canal com memória: quem carrega depois lê o estado pronto
lgpd:consentimento no document CustomEvent com { estado, categorias, versao, data, origem } no detail é o canal que avisa: reagir à troca sem recarregar

Um só não resolve: o atributo não notifica ninguém, e o evento não tem memória — um ouvinte registrado depois do disparo nunca saberia. A receita é sempre a mesma:

(function () {
  function ligarMedicao() {
    if (window.__medicaoLigada) return;      // idempotente: o evento repete
    window.__medicaoLigada = true;
    /* aqui, e só aqui, entra o <script> da ferramenta */
  }

  // 1. o que já estava decidido quando esta página abriu
  if (document.documentElement.matches('[data-lgpd-estado~="desempenho"]')) ligarMedicao();

  // 2. e o que a pessoa decidir daqui para a frente
  document.addEventListener('lgpd:consentimento', function (e) {
    if (e.detail.categorias.desempenho) ligarMedicao();
  });
})();

O separador por espaço não é capricho: é o que faz o seletor ~= funcionar, no JS e no CSS. Uma categoria desligada não é desligada retroativamente — script carregado não se descarrega; o caminho honesto para "desligar" é recarregar a página, e é por isso que o painel também aceita apagar os cookies do próprio domínio no navegador (fora do escopo do componente).

O evento dispara também na carga, com origem: "gravado" ou "pendente", para o consumidor ter um caminho de código só se preferir.

Ganchos

Gancho Papel
[data-lgpd] a raiz; é dela que toda busca parte. Leva o escopo de tom (.sec-deep) porque o bloco não mora em nenhuma <section>
data-lgpd-versao versão da política. Trocar o valor faz a barra voltar para todo mundo
[data-lgpd-bar] a barra. role="region" com aria-label, [hidden] no repouso
[data-lgpd-painel] o <dialog>; leva a primitiva .dialog-casca do core antes da classe local. showModal() traz foco preso, Esc, ::backdrop e devolução de foco
[data-lgpd-anuncio] região role="status" que confirma a escolha e recebe o foco quando a barra some
[data-lgpd-cat="…"] o <input type="checkbox"> de cada categoria; o valor é o nome dela no registro e no evento
[data-lgpd-aceitar], [data-lgpd-recusar], [data-lgpd-salvar] as três gravações. Aceitar e recusar existem na barra e no painel
[data-lgpd-abrir], [data-lgpd-fechar] abrem e fecham o painel
[data-lgpd-reabrir] o caminho de volta, no rodapé. Único gancho fora do bloco e único listener no document
--lgpd-z camada da barra; 150, entre a nav fixa (100) e o --z-overlay (200) do core. A barra não adota o --z-overlay: ver o gotcha da camada
--lgpd-veil-mix grau do véu do ::backdrop do painel; 84%, contra os 78% do --veil da casa
--lgpd-dur, --lgpd-saida, --lgpd-atraso entrada .7s, saída .32s (bate com SAIDA no script), atraso .45s
.lgpd-par a grade de duas colunas iguais dos botões do primeiro nível. É ela que garante a mesma caixa, e não só o mesmo desenho
--lgpd-larg largura do painel; 660px
.is-in a barra visível. Duração e atraso moram neste estado, não na regra base
.is-fixa a categoria travada; só troca o cursor do rótulo

Variações


Gotchas


Histórico

Projeto Onde Data Diferenças
autoral — 2026-09-06 Construído do zero na biblioteca. Sem referência única e sem uma linha copiada.

O padrão e de onde ele vem. Barra discreta em dois níveis com aceitar, recusar e um terceiro caminho, mais um modal de categorias com interruptores: é o mesmo desenho de três famílias — as plataformas de consentimento que os sites europeus passaram a usar depois do GDPR, os bancos e corretoras brasileiros depois da LGPD, e os veículos de imprensa. Da família vem a forma; nada mais.

O que foi decidido na tradução para o contrato, e o que na forma é regulação e não gosto:

Decisão Por quê
Os dois botões do primeiro nível na mesma primitiva (.btn-ghost) e na mesma grade de duas colunas iguais Um deles em .btn-primary é o padrão escuro clássico: recusar tem de ser tão fácil quanto aceitar. E mesmo estilo não basta — numa fila comum a pílula mais curta fica menor e lê como secundária
Recusar no primeiro nível; "Preferências" como terceiro caminho Esconder o "não" dentro de um segundo clique é a versão educada do mesmo padrão escuro
Barra sem scrim, sem trava de rolagem, sem foco preso Bloquear a leitura para forçar a escolha é o outro padrão escuro. Quem bloqueia é só o painel, e só depois de a pessoa pedir
role="region" rotulada, e não role="dialog" não-modal Um dialog promete um limite de foco que a barra deliberadamente não tem; anunciar "diálogo" sem gerenciar foco descreve errado o que está na tela. Uma região é achável pela lista de marcos do leitor de tela e é o que a barra de fato é
Painel em <dialog> + showModal() Mesmo critério do midia/showreel-expansivel: foco preso, Esc, ::backdrop, devolução de foco e página inerte vêm do elemento nativo — quatro comportamentos que não se reimplementam bem
Bloco fora de todo [data-rv], com transição própria §5 do contrato: blur(0) é filtro, e filtro prende descendente fixed. E o reveal do core é observador de entrada, que dispararia antes de o script saber se há escolha
Interruptor com <input type="checkbox"> real + .visually-hidden do core, desenho num irmão Teclado e leitor de tela de graça; o desenho num <span> irmão em vez de ::after do próprio input porque pseudo-elemento em controle de formulário depende de appearance:none e ainda varia entre motores
Estado por atributo no <html> + CustomEvent no document §7 proíbe escopo global. O atributo é o canal com memória, o evento é o que avisa; um sem o outro deixa metade dos consumidores de fora
Categorias lidas do DOM, nunca de uma constante no script Acrescentar categoria é acrescentar um <li>; registro, atributo e evento acompanham sozinhos
versao + data no registro gravado A versão é o que torna o consentimento revogável pelo lado de cá; a data é o que a área jurídica pede quando alguém exige prova
Nada de identificação no registro Consentimento é o único caso em que a biblioteca grava algo, e ele para exatamente aqui: sem id, sem e-mail, sem impressão digital

Normalizações aplicadas: prefixo lgpd- em todas as classes; zero cor crua (o véu é color-mix sobre --dark); primitivas do core consumidas em vez de recopiadas (.wrap, .btn/.btn-ghost/.btn-primary, .visually-hidden, .nota, escopo .sec-deep); media queries de largura no fim do arquivo e prefers-reduced-motion colada a cada regra que ela desliga; script em IIFE, ES5, com guard silencioso.

2026-09-06 — a casca do <dialog> virou primitiva. Os quatro componentes com <dialog> da biblioteca escreviam a mesma zeragem de caixa e o mesmo véu. Isso subiu para .dialog-casca no core/base.css, e o véu para o token --veil. Saíram deste arquivo padding, border, background, color, max-inline-size e o overflow do painel, mais a regra ::backdrop inteira; ficaram a largura, o teto de altura que anda em par com o .lgpd-quadro, e o grau do véu. Medido antes e depois com bin/medir.js, em 1440×900, 1440×420 e 390×844: a caixa do painel (660×774, 660×361.2 e 370×776.5), a do quadro, o teto de altura, o ::backdrop (0.84 de alfa, blur(6px)), a camada da barra (z-index:150), o foco inicial no botão de fechar e a rolagem interna do quadro saem idênticos. Mudou um computado, e era o objetivo: o max-width do painel, de 100% para none — a primitiva remove o limite que o navegador dá em vez de neutralizá-lo, e a largura usada continua a mesma nas três larguras medidas. Conferido depois com teclado de verdade: Tab circula dentro do painel e volta ao começo, Esc fecha, e o foco volta para o botão "Preferências".

Calibragem no primeiro uso. O texto é o que mais vai mudar: cada cliente carrega um conjunto diferente de terceiros, e a lista de categorias tem de sair da auditoria real do site, não deste exemplo. O desenho e a mecânica não deveriam precisar de ajuste — mas a altura da barra em celular (311px em 390×844) é o número a olhar primeiro em projeto cujo conteúdo principal fica no fim da tela.