Um botão numa dobra qualquer e um painel centrado que aparece por cima da página: cabeçalho parado com título e botão de fechar, corpo que rola por dentro com as bordas dissolvidas em gradiente, rodapé com a nota e o link para a página completa. O fundo escurece e desfoca.
O que ele resolve: o texto que não cabe na dobra e não merece uma página própria. Numa assessoria isso é sempre a mesma família — a política de investimentos resumida, o aviso ao investidor, a descrição inteira de um serviço que na dobra aparece em três linhas, a biografia completa de um assessor cujo card mostra só o nome e o cargo. Sem o modal, esse texto ou incha a seção ou vira uma página órfã que ninguém visita.
Ele é construído sobre o <dialog> nativo, com showModal(). Foco preso dentro do painel, Esc, devolução do foco a quem abriu e o resto da página inerte para tecnologia assistiva não estão escritos no script deste componente — vêm do elemento.
Quando usar
- Texto secundário de leitura opcional: resumo de política, metodologia, aviso, biografia longa, descrição completa de um serviço.
- Quando a dobra já diz o essencial e o modal só aprofunda. O resumo tem de sobreviver sozinho na página.
- Vários deles na mesma página. É o caso de projeto, não a exceção: o disparo é declarativo (
data-mdl-abrir="id"), e cada gatilho resolve o seu diálogo pelo id.
Quando não usar
- Informação obrigatória por lei, regulação ou contrato. Isso vive numa página com URL própria, indexável e linkável. O modal é atalho para ela — nunca o único lugar onde ela existe. Ver Compliance.
- Formulário de captura. Um formulário dentro de modal perde a URL, o botão voltar e a possibilidade de retomar. É
conversao/cta-formulario-leadouconversao/diagnostico-perfil. - Detalhe de um card de um trilho — aí é
overlay/card-expansivel-fullscreen, que já carrega o conteúdo nosdata-*do próprio card e faz a caixa crescer do lugar exato dele. - Vídeo — é
midia/showreel-expansivel, pelos controles nativos. - Menu de navegação — é
navegacao/drawer-fullscreen. - Aviso que a pessoa não pediu (cookies, promoção, newsletter). Modal que abre sozinho não é este componente, e é um padrão que se paga em taxa de rejeição.
Qual dos irmãos de overlay/
overlay/modal-dialogo |
overlay/card-expansivel-fullscreen |
|
|---|---|---|
| De onde vem o conteúdo | HTML de prosa, escrito à mão | os data-* de um card de trilho |
| Como entra | centrado, com blur indo a zero | cresce do lugar exato do card |
| Quantos por página | quantos precisar | um trilho deles |
| Diálogo | <dialog> nativo |
<div role="dialog"> |
| Para quê | política, aviso, biografia, metodologia | case, portfólio, detalhe com foto e CTA |
Padrão editorial
| Slot | Regra |
|---|---|
Olho (.eyebrow do core) |
classifica o documento: Política de investimentos, Informação obrigatória, Metodologia. Duas ou três palavras |
| Título | o mesmo assunto que o botão prometeu, sem repetir a frase do botão ao pé da letra |
| Corpo | prosa: p, ul.mdl-lista, strong. De 2 a 8 parágrafos — acima disso vira página |
| Nota do rodapé | uma linha dizendo que aquilo é resumo, e onde está o inteiro |
| Link do rodapé | a página real. Nunca # em produção |
O botão que abre é uma promessa de conteúdo, não um verbo de interface. Como definimos a política funciona; Saiba mais e Clique aqui não dizem o que vai aparecer, e um modal que decepciona custa mais que um que não abre.
A dobra tem de sobreviver sem o modal. Se apagar o botão deixa a seção incompreensível, o texto não era secundário e devia estar na página.
Dois modais na mesma dobra é o limite. Três botões lado a lado transformam a seção num índice, e aí o que se quer é uma lista de links para páginas de verdade.
Especificação dos assets
Nenhum. O componente é texto — não há foto, ícone proprietário nem fonte de terceiro. O único ícone é o × do botão de fechar, um <path> de duas linhas desenhado aqui mesmo.
Como usar
<link rel="stylesheet" href="componentes/overlay/modal-dialogo/estilo.css">
<script src="componentes/overlay/modal-dialogo/script.js"></script>
O gatilho vai onde fizer sentido, dentro de qualquer dobra. O <dialog> vai fora de toda seção, como último filho do <body>: dentro de uma dobra ele herdaria o escopo de tom dela, e o painel tem fundo próprio. Cada <dialog> declara o seu escopo (.sec-deep), que é o padrão autorizado no §3 do manual — entrar num escopo que o core já define, e não redefinir --fg por conta própria.
<button type="button" aria-haspopup="dialog" data-mdl-abrir="mdl-politica">Como definimos a política</button>
…
<dialog class="dialog-casca mdl sec-deep" id="mdl-politica" data-mdl aria-labelledby="mdl-politica-titulo"> … </dialog>
.dialog-casca antes da classe local, e ela não é opcional. É a primitiva do core que zera a caixa que o navegador dá ao <dialog> — padding, borda, fundo e os limites de max-content — e pinta o ::backdrop com --veil. Sem ela o diálogo abre com fundo branco, borda e o teto de calc(100% - 6px - 2em) do agente do usuário. O que a primitiva não cobre, de propósito, é o display de [open] e a coreografia de entrada: isso é forma, e continua no estilo.css deste componente.
Para um segundo modal, repita o par com outro id. Não há limite, e não há configuração.
Ganchos
| Gancho | O quê |
|---|---|
[data-mdl-abrir="id"] |
o <button> que abre. O valor é o id do diálogo |
[data-mdl] |
o <dialog>. Sem este atributo o par não é reconhecido |
[data-mdl-fechar] |
o botão de fechar. Pode haver mais de um |
[data-mdl-corpo] |
o bloco que rola. Recebe as duas faixas de corte |
.is-aberto |
classe posta pelo script um quadro depois do showModal(); é ela que dispara a entrada do painel e do ::backdrop |
--mdl-dur |
tempo da entrada e da saída. .42s. O script lê este valor para saber quanto esperar antes do close() |
--mdl-max |
largura de leitura do painel. 680px |
--dialog-pad |
recuo do painel até a borda da janela. É parafuso da .dialog-casca, não do prefixo: o diálogo usado como palco de tela cheia é o caso nomeado em que a primitiva não zera o padding. clamp(16px,5vh,56px) clamp(16px,5vw,56px) — 45px × 56px medidos a 1440×900, 14px × 12px abaixo de 640px |
--mdl-corte |
altura máxima da faixa de gradiente. 30px. O script lê este valor |
--mdl-fade-cima / --mdl-fade-baixo |
o corte de fato aplicado — escritas pelo script conforme a posição da rolagem |
Variações
| Variação | Como |
|---|---|
| Modal curto | dois ou três parágrafos: o painel encolhe, some a régua de rolagem e as faixas ficam em 0. É o segundo modal do preview |
| Sem rodapé | apagar o <footer class="mdl-rodape"> — só quando o link para a página completa já estiver no corpo |
| Largura de leitura | --mdl-max: 560px para aviso curto, 820px quando o corpo tem tabela |
| Ritmo | --mdl-dur — muda a entrada e a saída de uma vez |
| Folha de borda a borda no celular | --dialog-pad:0 na media query de 640px e border:0 no painel. Some o véu nas laterais, que é o que dá a leitura de camada |
Gotchas
-
displaysó em.mdl[open]. O<dialog>nascedisplay:nonena folha do agente do usuário, e qualquer regra de autor comdisplayvence essa. Escrever.mdl{display:flex}deixaria os dois diálogos visíveis o tempo todo, empilhados sobre a página. É a mesma armadilha do §6.5 do contrato ([hidden]que não esconde), na versão do elemento nativo. -
showModal(), nuncashow(). ÉshowModalque traz o foco preso, o::backdrope a página inerte. Comshow()o diálogo abre como caixa comum e a página continua navegável por trás — o que parece funcionar até alguém usarTab. -
Flex para centrar, não grid. Num grid de linha automática a altura da linha é o conteúdo, então
max-block-size:100%no painel resolve contra a linha que ele mesmo acabou de esticar: o limite nunca morde. Medido a 1440×900 comdisplay:grid: janela de 900, diálogo de 900, painel de 919,5px e o corpo comscrollHeightigual aoclientHeight— ou seja, régua de rolagem nenhuma, e o texto cortado pela borda da janela. Em flex a porcentagem resolve contra a caixa de conteúdo do container, que é definida, e o painel para em 810px. -
min-block-size:0no corpo que rola. Item de flex nasce commin-block-size:auto, que o proíbe de encolher abaixo do próprio conteúdo. Sem essa linha o painel cresce até a altura do texto e ooverflow-y:autonunca chega a valer. É o segundo motivo, independente do anterior, para o mesmo sintoma. -
A saída termina antes do
close().close()apaga o diálogo no mesmo quadro em que é chamado — animar depois dele não anima nada. Por isso o script tira a classe, espera--mdl-dure só então fecha. O tempo é lido do CSS: mudar a transição sem mudar osetTimeout(ou o contrário) é o jeito clássico de o painel sumir no meio do fade. -
O
Escé interceptado. O nativo chamariaclose()na hora, e a saída não existiria. O script escutacancel, dápreventDefault()e refaz o fechamento pelo caminho animado — é o que faz oEscsair igual ao botão. Sobprefers-reduced-motiono caminho é o mesmo, com espera zero. -
O clique no fundo confere o
mousedowntambém. O::backdropnão é alvo de evento: quem recebe o clique é o próprio<dialog>, que o CSS faz cobrir a janela inteira, ee.target === dialogsignifica "caiu fora do painel". Só que quem começa a selecionar um parágrafo e solta o mouse fora do painel produz um clique cujo alvo é o diálogo — sem a conferência domousedown, o modal fecharia levando a seleção junto, no meio de uma leitura. -
A trava de rolagem é do core, e foi este componente que a definiu.
LIB.travarRolagem()/LIB.soltarRolagem():overflow:hiddenno<html>— o elemento que de fato rola a página, e não o<body>, que já carregaoverflow-x:hiddendo core —, valor anterior guardado e devolvido, e um contador que solta só na última.overflow:hiddennão zera oscrollTopdo<html>, então a posição volta sozinha: não háscrollTonenhum, e é por isso que não há salto. Medido nos dois lados do refactor, a 1440×900:scrollY120 antes de abrir, 120 com o modal aberto e a roda girando 700px sobre o véu, 120 depois doEsc;clientWidth1440 nos três momentos, porque oscrollbar-gutter:stabledo core mantém a calha reservada. O contador local deste script (abertos) foi apagado: ele não enxergava a trava de outra sobreposição da mesma página, que era exatamente o bug que o do core resolve. -
outline-offset:-3pxno corpo. O:focus-visibledo core desenha o contorno 3px para fora da caixa, e a máscara em gradiente recorta tudo que é pintado fora da borda do elemento — inclusive o contorno. O anel ficaria invisível justamente no elemento que recebe o foco ao abrir. -
tabindex="0"no corpo que rola não é enfeite de ARIA. Um bloco comoverflownão entra na ordem de tabulação do Chrome, e quem navega por teclado não consegue rolar o texto. Oautofocuspõe o foco ali, e não no botão de fechar: quem chegou pelo teclado quer ler, não sair. -
Nada
position:fixeddentro do painel. Ele termina a entrada emfilter:blur(0)— que continua sendo um filtro, e portanto bloco de contenção para descendentefixed, exatamente como o[data-rv]do core (§5 do manual). -
Nenhum
<dialog>levadata-rv. O reveal do core esconde o elemento até ele cruzar a dobra, e um<dialog>não está no fluxo da página: ele nunca cruzaria, e o painel abriria invisível. -
Os
var()do::backdroptêm fallback — e o véu, agora, não tem. Até o Chrome 121 o::backdropnão herdava custom property do<dialog>de origem: o valor chegava vazio, a declaração inteira era descartada e o que dependia dela sumia. Neste Chrome a herança funciona (medido:transition-durationdo::backdrop=0.42s, que só pode ter vindo do--mdl-durdo diálogo), e o fallback dentro dovar()da transição existe para onde ela ainda não funciona. O que mudou com a primitiva: o fundo passou a vir de.dialog-casca::backdrop{background:var(--veil)}, e--veilmora no:root— num navegador anterior ao 121 o véu sumiria, com o texto da página aparecendo por trás do painel. É a única perda conhecida da adoção, e ela é do core, não deste componente: quem precisar cobrir navegador dessa idade dá um fallback aovar(--veil)nocore/base.css. -
O véu passou de 72% para os 78% do
--veil. Erargba(var(--dark-rgb,10,10,10),.72)escrito à mão; virou o token da casa. Seis pontos de alfa que ninguém identifica sem as duas telas lado a lado, e um número a menos vivendo em dois lugares. A saturação continua local, e por um motivo técnico: a primitiva serveblur(var(--dialog-blur,6px)), ebackdrop-filteré uma propriedade só — não há como somarsaturate(120%)à declaração dela. Por isso este componente reescreve o filtro inteiro (blur(10px) saturate(120%), medido igual antes e depois) e o parafuso--dialog-blurnão tem efeito aqui.
Sem JavaScript
O <dialog> sem [open] fica invisível e o botão não faz nada. Isso é aceitável e é o comportamento correto para conteúdo secundário — a dobra continua legível, o resumo está lá, nada fica em branco.
Mas cobra a regra editorial que a seção Compliance detalha: o modal é atalho, e o conteúdo dele tem de existir em outro lugar acessível sem script.
Movimento reduzido / toque
Com prefers-reduced-motion: reduce o diálogo continua abrindo — ele foi pedido, não anda sozinho. Some só a coreografia: painel e véu nascem no estado final (medido: transition-duration 0s nos dois), o script lê LIB.reduce e fecha na hora, sem esperar transição que não existe.
No toque não há nada que dependa de hover. O corpo rola com o dedo, e overscroll-behavior:contain impede que chegar ao fim do texto continue rolando a página por baixo. Em 390×844 o painel mede 366×816 dentro da janela, com o corpo rolando 533px por dentro.
Por teclado: Tab chega ao gatilho, Enter ou Espaço abre, o foco entra no corpo do painel, Tab circula entre corpo, botão de fechar e link do rodapé sem sair do diálogo, Esc fecha e o foco volta para o botão que abriu (medido). Nada disso foi escrito aqui — é tudo do <dialog>.
Compliance
O componente não calcula, não compara e não captura dado nenhum. O que ele pisa é o terreno do texto regulado, e são três regras:
-
O modal nunca é o único lugar onde a informação vive. Aviso ao investidor, política de privacidade, termos de uso, política de investimentos, informação de remuneração — tudo isso tem de ter página própria, com URL, indexável e linkável. O modal é atalho para ela, e é por isso que o rodapé do painel traz o link. Em produção, aquele
href="#"aponta para a página real. Sem script, o modal não abre: se a informação obrigatória só existisse ali, ela deixaria de existir. -
O texto de exemplo é editorial, não jurídico. Os dois modais do preview trazem prazos e periodicidades ilustrativos (a cada semestre, até trinta dias) e a descrição do método em faixas. Nada disso foi revisado por área técnica ou jurídica de ninguém. Trocar pelo texto aprovado pelo compliance do cliente antes de publicar.
-
Nada aqui promete resultado. O texto de exemplo diz explicitamente que a política define desenho e limites, e que o resultado depende do mercado; que rentabilidade passada não garante futuro; e que a remuneração é a taxa previamente acordada, que não muda conforme o produto alocado. Se o consumidor trocar o conteúdo, essas três afirmações são o piso, não o teto.
Nada é gravado: o componente não usa localStorage, sessionStorage, cookie nem chamada de rede. O estado do modal morre no recarregamento.
Histórico
| Origem | Quando | O quê |
|---|---|---|
| autoral | 2026-09-06 | Construído do zero. Padrão de domínio comum, sem referência única |
Origem. O modal de texto longo sobre <dialog> nativo — cabeçalho fixo, corpo que rola, rodapé com a saída — aparece em praticamente todo site institucional e de produto: política de privacidade, termos, aviso legal, biografia inteira de uma pessoa cujo card mostra só o nome. Padrão sem dono. Nada foi copiado: reescrito do zero dentro do contrato desta biblioteca. O registro consolidado vai em projetos/autorais.md.
Por que existe ao lado do irmão. O overlay/card-expansivel-fullscreen já abre uma sobreposição de conteúdo, e a categoria não precisava de dois modais. A divisão é pela origem do conteúdo: lá o detalhe nasce dos data-* de um card de um trilho, e a caixa cresce do lugar exato dele — pressupõe um card de origem, uma foto e um trilho. Aqui o conteúdo é HTML de prosa escrito à mão, o gatilho é um botão de texto em qualquer dobra, e não há card nenhum de onde crescer. Tentar usar o irmão para uma política de investimentos significaria empurrar quatro parágrafos e uma lista para dentro de um atributo data-desc.
Decisões da tradução para o contrato. <dialog> nativo com showModal, pelo mesmo critério do midia/showreel-expansivel e do <input type="range"> do midia/comparador-antes-depois: usar o elemento nativo em vez de reimplementar o comportamento dele. A saída animada antes do close(), com o tempo lido de --mdl-dur. O Esc interceptado no evento cancel para sair pelo mesmo caminho do botão. O corte do texto em máscara de gradiente escrita pelo script, e não em sombra dura. A trava de rolagem em overflow:hidden no <html>, guardando e devolvendo o valor anterior, com contador para modais empilhados. E o painel centrado por flex, e não por grid, pela razão medida nos gotchas.
06/09/2026 — o que era daqui virou do core. No mesmo dia em que nasceu, este componente entregou ao core/ três coisas que estavam escritas em quatro sobreposições ao mesmo tempo, e passou a consumi-las: a casca do <dialog> (.dialog-casca), a trava de rolagem (LIB.travarRolagem, cujo alvo <html> e cujo critério saíram daqui) e o véu (--veil). Saíram do arquivo local seis declarações de CSS (padding, border, background e os dois max-* do diálogo, mais o background do ::backdrop) e sete linhas de JS (o miolo das duas funções de trava, o contador abertos e a variável overflowAnterior); --mdl-margem não sumiu, virou --dialog-pad. Nada mudou na tela: padding do diálogo 45px 56px a 1440×900 e 14px 12px a 390, painel 680×810, corpo 608px de altura com 718 de conteúdo, foco entrando no corpo e voltando ao gatilho, Esc igual. Mudou de propósito um valor só: o véu, de 72% para 78%.
Reverificação (2026-09-06, Chrome headless, roteiro próprio, antes e depois da adoção): o mesmo roteiro em 1440×900, 390×844 e --reduce, comparando 741 valores medidos nos dois componentes de overlay/. Bateram todos, menos os esperados: a classe nova no <dialog>, max-width/max-height passando de 100% para none sem que uma única caixa mudasse (diálogo 1425×900, painel 680×810, corpo 678×608,1 — idênticos ao pixel), o --mdl-margem virando --dialog-pad com o mesmo computado, o alvo da trava saindo do script para o core, e o véu de 72% para 78%. scrollY 120 e clientWidth 1440 nos três momentos da trava; console limpo nos seis cenários; bin/conformidade.py em 0 alertas.
Divergências conhecidas vs. produção: nenhuma — o componente nasceu aqui. Calibragem no primeiro uso: espere ajustar --mdl-max ao corpo de texto do cliente (680px é confortável para a --sans do tema liberta-wealth; uma fonte de corpo maior pede mais coluna) e a altura de --mdl-corte se o tema usar entrelinha muito aberta, onde 30px podem comer mais de meia linha.