Sistema de componentes sem estilos · Novo recurso

Sistema de design Headless UI.

Headless UI fornece componentes de interação sem estilos para React, como diálogos, listas de opções e interruptores. Este guia mostra como combinar o comportamento nativo de foco, seleção e pop-up desses componentes com CSS comum, usando como exemplo um fluxo de compartilhamento de documentos.

Guia OpenDesign9 capítulos27 entradas de referênciaGuia de aplicação original
01

Prévia da interface do sistema de design Headless UI

Abra a caixa de diálogo de compartilhamento, valide um e-mail, escolha uma permissão pelo teclado, adicione um convite local e alterne o estado do link público.

Compartilhamento de documentos com primitivas headless nativasAbrir exemplo completo
@headlessui/react 2.2.10 nativo com React 19. Todas as cores, tipografia, espaçamentos e layout são tokens de design originais deste exemplo CSS, não tokens oficiais de Headless UI. · Referência de implementação
02

Referência de design do sistema de design Headless UI

Headless UI não tem uma escala oficial de tokens visuais. Cada valor numérico ou de cor abaixo pertence a este exemplo de OpenDesign; a documentação vinculada descreve o comportamento e as APIs de estado. Pacote testado: @headlessui/react 2.2.10.

Papéis de cor originais de CSS

Fonte
Nome / RegraValor / ConfiguraçãoUso
--canvas#f5f5f4 Área de trabalho do documento.
--surface#ffffff Papel, painel de acesso e caixa de diálogo.
--ink#1c1917 Texto principal.
--muted#57534e Texto secundário.
--line#d6d3d1 Bordas do painel e dos campos de entrada.
--accent#4338ca Ação principal e contorno de foco.
--accent-soft#eef2ff Opção em foco e fundo do avatar.
--error#b91c1c Texto e borda de e-mail inválido.

Espaçamento e geometria originais

Fonte
Nome / RegraValor / ConfiguraçãoUso
--space-1 / --space-24px / 8pxPequenos incrementos.
--space-3 / --space-412px / 16pxEspaçamento entre controles e recuo em dispositivos móveis.
--space-6 / --space-824px / 32pxPreenchimento do painel e separação principal.
--radius-control8pxBotões e campos de entrada.
--radius-panel16pxPapel e caixa de diálogo.
.dialog-panel widthmin(100%, 460px)Sobreposição delimitada em telas estreitas.
.dialog-panel max-heightcalc(100% - 16px)Mantém espaço dentro do contêiner de posicionamento fixo.

Tipografia e escolhas responsivas originais

Fonte
Nome / RegraValor / ConfiguraçãoUso
Body16px / 1.5 system-uiNão é necessária uma fonte externa.
Screen title28px / 1.25Identidade do documento.
Document headline32px; 26px below 700pxHierarquia editorial do conteúdo.
Dialog heading20px / 1.3Identifica a tarefa de compartilhamento.
Labels and helper copy14pxContexto do campo.
Error and access description13pxFeedback persistente.
Breakpoint700pxOs painéis do documento e de acesso ficam empilhados.

Contratos de interação nativos

Fonte
Nome / RegraValor / ConfiguraçãoUso
Dialog open / onCloseControlled boolean / dismissal callbackA tecla Escape e a interação fora da caixa solicitam o fechamento; não salvam nada.
DialogPanelContent boundaryO fundo escurecido e o contêiner de posicionamento ficam fora deste limite.
Listbox value / onChangeControlled permission stringA navegação pelo teclado atualiza a permissão selecionada.
data-focus / data-selectedNative state attributesCSS separa o foco do teclado da seleção confirmada.
Switch checked / onChangeControlled booleandata-checked define o estilo do estado visual.
03

Guia completo do sistema de design Headless UI

Um guia prático para escolher, aplicar e avaliar este sistema em uma interface real.

DESIGN-headless-ui.md

Orientações completas, um ponto de partida para a implementação e links para o código-fonte.

Baixar o guia

Sistema de design Headless UI: componha o comportamento antes de estilizar a tela

Componha o comportamento antes de estilizar a tela

Dialog, Listbox e Switch representam interações diferentes. Dialog controla uma tarefa delimitada e seu contexto de foco; Listbox representa uma única opção confirmada; Switch representa uma configuração booleana independente. Combiná-los não exige combinar seus estados: o rascunho do convite, a permissão selecionada e a configuração do link público permanecem separados.

Com base neste guia de OpenDesign

O exemplo usa o componente React API com importações diretas, como DialogPanel e ListboxButton. A caixa de diálogo contém uma caixa de listagem, então o posicionamento do menu e o fechamento precisam funcionar dentro de uma sobreposição existente. Comece por essa composição antes de adicionar ao redor dela o painel do documento e o resumo de acesso. O CSS fornecido apresenta uma aparência completa para essas partes nativas.

Sistema de design Headless UI: estrutura de Dialog, Listbox e Switch

Defina limites intencionais para a sobreposição

DialogBackdrop é irmão do contêiner de posicionamento fixo. DialogPanel é o painel branco delimitado dentro desse contêiner; ele não ocupa toda a janela de visualização. DialogTitle identifica a tarefa, e Description explica o que acontece ao fechar. O campo de entrada nativo tem data-autofocus, e Headless UI gerencia o comportamento de foco da caixa de diálogo ao redor.

Consulte a especificação de apoio

A caixa de listagem seleciona um valor; o interruptor altera um valor booleano

ListboxButton mostra a função selecionada. ListboxOptions usa anchor="bottom" e uma largura baseada em --button-width, para que o menu fique alinhado ao botão que o aciona. Cada ListboxOption tem um valor de texto estável. Os atributos nativos data-focus e data-selected controlam dois estilos CSS distintos.

O Switch do link público fica em Field com Label, em vez de ser um botão decorativo sem rótulo. Seu estado marcado altera o controle acessível e a frase explicativa visível. O convite local é independente da configuração do link público.

Consulte a especificação de apoio

Sistema de design Headless UI: separe seleção, fechamento e confirmação local

O modelo de estado usado aqui

Ao abrir, um novo rascunho de destinatário é iniciado com a função Can view. Cancelar, pressionar Escape ou clicar fora fecha a caixa de diálogo; nenhuma dessas ações adiciona um destinatário. Ao enviar, o e-mail é validado e, se o endereço já estiver presente, é rejeitado. Em seguida, o convite é adicionado à memória e a caixa de diálogo é fechada. A confirmação informa explicitamente que nenhum e-mail foi enviado.

A caixa de listagem de permissões altera a função do rascunho, não o acesso de um destinatário existente. O estado do link público altera um valor booleano separado. Assim, evita-se o erro comum de tratar cada evento de fechamento como persistência de permissão.

  • Entrada inválida: mantenha a caixa de diálogo aberta e associe o erro ao e-mail.
  • Destinatário duplicado: mantenha inalterada a lista de acessos existente.
  • Convite local aceito: atualize a lista e anuncie o resultado, disponível apenas na prévia.

Sistema de design Headless UI: estilize a seleção e o foco do teclado de forma independente

Estilize a seleção e o foco do teclado de forma independente

ListboxOption expõe data-focus e data-selected para condições diferentes. A opção em foco recebe o tratamento de destaque claro, enquanto a permissão confirmada recebe uma marca de seleção. Assim, quem usa o teclado pode percorrer as alternativas sem perder de vista a opção atual.

A mesma distinção se aplica fora do menu: o papel de erro pertence ao campo de destinatário inválido, enquanto a aparência marcada do Switch corresponde ao acesso pelo link público. Restrinja CSS ao estado do componente que ele descreve. Um seletor amplo que aplicasse a mesma cor a todos os elementos em foco ou marcados ocultaria esses significados diferentes.

css
:root { --accent: #4338ca; --accent-soft: #eef2ff; --error: #b91c1c; }
.role-option[data-focus] { background: var(--accent-soft); }
.role-option[data-selected] .selected { visibility: visible; }

Referência de código

Sistema de design Headless UI: torne a identidade do documento e as permissões fáceis de entender

Configurações tipográficas reproduzíveis

O título do documento usa 28px com altura de linha de 1.25, enquanto o título da caixa de diálogo usa 20px com altura de linha de 1.3. O texto do corpo usa 16px/1.5 na fonte de sistema da plataforma. Os rótulos dos campos e o texto auxiliar usam 14px. Esses valores exatos estão em style.css; não é necessário inferi-los de uma captura de tela.

O endereço de e-mail na lista de acessos usa overflow-wrap:anywhere para impedir que ele force a coluna a ficar mais larga. A permissão permanece em uma linha separada. Dentro da caixa de diálogo, o título completo do documento e a permissão selecionada ficam visíveis no momento em que o convite é adicionado.

Sistema de design Headless UI: posicionamento completo da sobreposição e CSS responsivo

O layout não fica oculto em um pré-requisito

A folha de estilos fornecida inclui toda a sobreposição: posicionamento fixo com inset, opacidade do fundo, z-index, largura máxima, rolagem vertical e espaçamento interno. Abaixo de 700px, os painéis do documento ficam empilhados e o diálogo reduz o espaçamento interno. Não se pressupõe uma compilação do Tailwind.

Mantenha o contêiner de posicionamento separado de DialogPanel. O contêiner ocupa a área disponível da janela de visualização e fornece espaçamento nas bordas; o painel fornece uma superfície de conteúdo com limites definidos. Para diálogos com conteúdo mais longo, crie uma área rolável para que o campo de destinatário, a escolha de permissão e as ações de fechar continuem acessíveis em larguras estreitas.

css
.dialog-position { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; padding: 16px; overflow-y: auto; }
.dialog-panel { width: min(100%, 460px); max-height: calc(100% - 16px); overflow-y: auto; }

Referência de código

Use o estado e o comportamento de foco nativos do sistema de design Headless UI

Verificação pelo teclado

Abra o diálogo e confirme que o foco inicial está no destinatário. Pressione Tab para chegar ao acionador de permissão; use as teclas de seta e Enter para escolher uma função. Escape fecha o popup ou o diálogo ativo, de acordo com o contexto de foco atual do componente nativo. Depois de fechar o diálogo, o foco deve retornar ao acionador de compartilhamento.

O e-mail usa aria-invalid e aria-describedby para exibir um erro personalizado. A marca de seleção em uma opção é decorativa, pois o Listbox fornece a semântica de seleção. O controle de link público tem um rótulo visível, e a frase explicativa permanece após a alternância. A configuração de movimento reduzido CSS remove a transição decorativa do botão deslizante.

Consulte a especificação de apoio

Use uma única geração de React API de forma consistente no sistema de design Headless UI

Use uma única geração de React API de forma consistente

A entrada completa React monta App, cuja folha de estilos fornece a geometria da sobreposição e os seletores de estado. DialogPanel, DialogBackdrop, ListboxButton e ListboxOptions são importações diretas no exemplo verificado da versão 2.2.10. As grafias antigas de componentes compostos devem ser migradas deliberadamente, em vez de misturadas a esta composição.

ListboxOptions usa anchor="bottom" e a variável nativa --button-width para alinhar as opções ao acionador. Esses detalhes fazem parte da integração, não são enfeites opcionais: remover o contêiner de posicionamento ou substituir a largura por um valor fixo sem relação com o acionador pode comprometer um popup aninhado, mesmo que o campo fechado ainda pareça correto.

  1. Instale @headlessui/react no projeto React.
  2. Salve App.jsx e style.css a partir dos exemplos e, em seguida, substitua main.jsx conforme mostrado.
  3. Inicie o servidor de desenvolvimento e teste o fechamento do diálogo, a seleção de permissões e a recuperação após e-mails duplicados.

Inspecione uma interação real de permissão no sistema de design Headless UI

Critérios de aceitação

Uma revisão útil começa com um documento e um destinatário existente, não com uma galeria de componentes vazia. Adicione um convite válido com a permissão Pode comentar e, em seguida, reabra o diálogo e tente usar o mesmo e-mail. Confirme que a linha original é mantida e que a duplicata gera um erro específico. Ative o link público e confira a explicação correspondente.

O exemplo demonstra apenas o estado local da interface. Ele não oferece busca de destinatários nem back-end de compartilhamento. Conectar um serviço real exige um modelo de respostas para estados pendentes e de erro, além de uma política explícita para alterações simultâneas de permissões; os elementos primitivos nativos não definem regras de autorização.

Exemplos de componentes do sistema de design Headless UI

App.jsx — composição de compartilhamento nativa

Dialog controlado completo, Listbox de permissões, Switch com rótulo e validação local.

jsx
import React, { useState } from 'react';
import { Dialog, DialogBackdrop, DialogPanel, DialogTitle, Description, Listbox, ListboxButton, ListboxOptions, ListboxOption, Switch, Field, Label } from '@headlessui/react';
import './style.css';
const roles = ['Can view', 'Can comment', 'Can edit'];
export default function App() {
 const [open,setOpen]=useState(false),[role,setRole]=useState(roles[0]),[publicLink,setPublicLink]=useState(false),[email,setEmail]=useState(''),[error,setError]=useState(''),[notice,setNotice]=useState('');
 const [people,setPeople]=useState([{email:'[email protected]',role:'Can edit'}]);
 const start=()=>{setEmail('');setError('');setRole(roles[0]);setOpen(true)};
 const submit=e=>{e.preventDefault();if(!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)){setError('Enter a complete email address.');return;}if(people.some(p=>p.email===email)){setError('This person already appears in the access list.');return;}setPeople([...people,{email,role}]);setOpen(false);setNotice('Invitation added to this local example only. No email was sent.');};
 return <main className='sharing-shell'>
  <header><div><span className='eyebrow'>HEADLESS UI / CUSTOM SURFACE</span><h1>Quarterly brief</h1><p className='muted'>Research · Updated today</p></div><button className='primary' onClick={start}>Share document</button></header>
  <div className='document-grid'><article className='paper'><span className='tag'>WORKING DOCUMENT</span><h2>Make the next decision clearer.</h2><p>Three research priorities for the coming quarter, shared with the people who need to review them.</p><div className='rule'/><h3>01 / Understand the handoff</h3><p>Compare where context is lost between research notes and the next product decision.</p><h3>02 / Review access deliberately</h3><p>Keep editing rights separate from the ability to read a document.</p></article>
  <aside className='access'><h2>People with access</h2><p className='muted'>The list below is local demonstration state.</p>{people.map((p,i)=><div className='person' key={p.email}><span className='avatar'>{i===0?'AK':'+'}</span><div><strong>{p.email}</strong><small>{p.role}</small></div></div>)}<Field className='link-setting'><Label>Public link</Label><Switch checked={publicLink} onChange={setPublicLink} className='switch'><span /></Switch></Field><p className='link-detail'>{publicLink?'Anyone with the example link could view this document. This changes only the local preview.':'Restricted to the named people in this example.'}</p><p role='status' className='notice'>{notice}</p></aside></div>
  <Dialog open={open} onClose={()=>setOpen(false)} className='dialog-root'><DialogBackdrop className='backdrop'/><div className='dialog-position'><DialogPanel className='dialog-panel'><DialogTitle as='h2'>Share quarterly brief</DialogTitle><Description className='muted'>Choose one recipient and a permission. Closing discards this invitation draft.</Description><form noValidate onSubmit={submit}><label className='field-label' htmlFor='recipient'>Recipient email</label><input id='recipient' data-autofocus type='email' value={email} onChange={e=>{setEmail(e.target.value);setError('')}} aria-invalid={!!error} aria-describedby={error?'email-error':undefined} placeholder='[email protected]'/>{error&&<p id='email-error' role='alert' className='error'>{error}</p>}<Listbox value={role} onChange={setRole}><Label className='field-label'>Permission</Label><ListboxButton className='role-button'>{role}<span aria-hidden='true'>⌄</span></ListboxButton><ListboxOptions anchor='bottom' className='role-options'>{roles.map(value=><ListboxOption key={value} value={value} className='role-option'><span>{value}</span><span className='selected' aria-hidden='true'>✓</span></ListboxOption>)}</ListboxOptions></Listbox><p className='muted permission-help'>{role==='Can edit'?'May change document content in this example.':role==='Can comment'?'May add comments without editing content.':'May read the document without changing it.'}</p><div className='dialog-actions'><button type='button' onClick={()=>setOpen(false)}>Cancel</button><button className='primary' type='submit'>Add invitation</button></div></form></DialogPanel></div></Dialog>
  <footer>Native Dialog, Listbox, and Switch behavior. Original CSS below supplies all visual values; Headless UI does not ship this palette.</footer>
 </main>;
}

Referência de código

  • Nenhum e-mail é enviado, nem qualquer solicitação é feita.
  • Escape e Cancelar descartam o convite não salvo.

style.css — sistema visual original completo

Todos os tokens visuais, seletores de estado, posicionamento da sobreposição e regras responsivas exigidos por App.jsx. Estas são escolhas do exemplo, não configurações padrão oficiais de Headless UI.

css
:root{--canvas:#f5f5f4;--surface:#fff;--ink:#1c1917;--muted:#57534e;--line:#d6d3d1;--accent:#4338ca;--accent-soft:#eef2ff;--error:#b91c1c;--space-1:4px;--space-2:8px;--space-3:12px;--space-4:16px;--space-6:24px;--space-8:32px;--radius-control:8px;--radius-panel:16px;font:16px/1.5 system-ui,sans-serif;color:var(--ink);background:var(--canvas)}*{box-sizing:border-box}body{margin:0}button,input{font:inherit}button{cursor:pointer;border:1px solid var(--line);border-radius:var(--radius-control);padding:10px 16px;background:var(--surface);color:var(--ink);font-weight:600}button:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:3px}.primary{background:var(--accent);color:white;border-color:var(--accent)}.sharing-shell{max-width:1120px;margin:auto;padding:32px}.sharing-shell>header{display:flex;align-items:center;justify-content:space-between;gap:24px;margin-bottom:32px}.eyebrow{font-size:11px;letter-spacing:.1em;color:var(--accent);font-weight:700}h1{font-size:28px;line-height:1.25;margin:8px 0}h2{font-size:20px;line-height:1.3;margin:0 0 12px}h3{font-size:16px;margin:24px 0 8px}p{margin:0 0 16px}.muted{color:var(--muted);font-size:14px}.document-grid{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(260px,1fr);gap:24px}.paper,.access{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius-panel);padding:32px}.paper h2{font-size:32px;max-width:15ch;margin-top:24px}.paper p{max-width:48ch}.tag{font-size:11px;font-weight:600;border:1px solid var(--line);padding:4px 8px;border-radius:4px}.rule{height:1px;background:var(--line);margin:24px 0}.person{display:flex;gap:12px;padding:16px 0;border-bottom:1px solid var(--line)}.person strong{display:block;font-size:14px;overflow-wrap:anywhere}.person small{color:var(--muted)}.avatar{flex:none;width:32px;height:32px;border-radius:50%;background:var(--accent-soft);color:var(--accent);display:grid;place-items:center;font-size:12px}.link-setting{display:flex;align-items:center;justify-content:space-between;margin-top:24px;font-weight:600}.switch{padding:3px;width:44px;height:26px;border:0;border-radius:999px;background:#a8a29e}.switch span{display:block;width:20px;height:20px;border-radius:50%;background:white;transition:transform .15s}.switch[data-checked]{background:var(--accent)}.switch[data-checked] span{transform:translateX(18px)}.link-detail{margin-top:12px;font-size:13px;color:var(--muted)}.notice{font-size:13px;color:var(--accent)}footer{font-size:12px;color:var(--muted);margin-top:24px}.dialog-root{position:fixed;inset:0;z-index:20}.backdrop{position:fixed;inset:0;background:rgb(28 25 23/.45)}.dialog-position{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;padding:16px;overflow-y:auto}.dialog-panel{width:min(100%,460px);max-height:calc(100% - 16px);overflow-y:auto;background:var(--surface);padding:32px;border-radius:var(--radius-panel);box-shadow:0 24px 64px rgb(0 0 0/.22)}.field-label{display:block;font-size:14px;font-weight:600;margin:20px 0 8px}input[type=email]{display:block;width:100%;padding:10px 12px;border:1px solid var(--line);border-radius:var(--radius-control);background:white;color:var(--ink)}input[aria-invalid=true]{border-color:var(--error)}.error{font-size:13px;color:var(--error);margin:8px 0}.role-button{display:flex;justify-content:space-between;width:100%;text-align:left}.role-options{z-index:30;width:var(--button-width);--anchor-gap:4px;background:var(--surface);border:1px solid var(--line);padding:4px;border-radius:8px;box-shadow:0 8px 24px rgb(0 0 0/.12);outline:none}.role-option{display:flex;justify-content:space-between;padding:10px;border-radius:4px;cursor:pointer;font-size:14px}.role-option[data-focus]{background:var(--accent-soft)}.role-option .selected{visibility:hidden;color:var(--accent)}.role-option[data-selected] .selected{visibility:visible}.permission-help{margin-top:12px}.dialog-actions{display:flex;justify-content:flex-end;gap:12px;margin-top:24px}@media(max-width:700px){.sharing-shell{padding:20px}.sharing-shell>header{align-items:flex-start;flex-direction:column}.document-grid{grid-template-columns:1fr}.paper,.access{padding:24px}.paper h2{font-size:26px}.dialog-panel{padding:24px}}@media(prefers-reduced-motion:reduce){.switch span{transition:none}}

Referência de código

  • Mantenha data-focus e data-selected distintos.
  • Não é necessário usar Tailwind.
04

Configuração do agente do sistema de design Headless UI

Ponto de partida para implementação

Os arquivos completos de estilos e componentes estão acima. Requer a versão do Node compatível com a versão selecionada do Vite. A demonstração inclui o mesmo pacote de componentes nativos localmente.

texto
# Terminal
npm create vite@latest sharing-demo -- --template react
cd sharing-demo
npm install
npm install @headlessui/[email protected]

# Save the complete examples as src/App.jsx and src/style.css.
# Replace src/main.jsx with:
import React from 'react';
import {createRoot} from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')).render(<App/>);

# Terminal
npm run dev

Um prompt para seu projeto

Prompt para agente de IA
Use App.jsx e style.css de Headless UI fornecidos. Preserve a composição nativa de Dialog, Listbox e Switch, mantenha as permissões provisórias separadas do acesso confirmado e preserve todos os seletores de foco e de estado selecionado. Substitua a inclusão local de convites pela nossa API autorizada, incluindo estados pendentes e de falha; nunca salve em onClose. Não descreva a paleta personalizada de CSS como um tema oficial de Headless UI.
05

Fontes e escopo do sistema de design Headless UI

Configuração visual original de OpenDesign e exemplo de React usando o comportamento nativo de Headless UI 2.2.10. Os valores de CSS são explicitamente escolhas do aplicativo; Headless UI não oferece este tema. Os convites e as alterações de permissões existem apenas na memória da prévia local.