Biblioteca de componentes
38 componentes
v0.9.0GitHub
Componentes de UI
Cada exemplo abaixo é renderizado de verdade, com o mesmo CSS e o mesmo JavaScript do site. As
tabelas de props são lidas do código na hora, então não envelhecem.
Último recurso: php artisan vendor:publish --tag=goognet-ui-views copia as views para o
site. A partir daí elas deixam de receber as atualizações do pacote.
Button
<x-ui.button>
Botão de ação. Vira <a> sozinho quando recebe href, então serve também para call-to-action que navega.
x-ui.button
Prop
Padrão
variant
null
icon
null
icon-trailing
null
size
null
href
null
external
false
type
'button'
square
false
loading
false
disabled
false
rounded
null
Altura, arredondamento, peso da fonte e sombra vêm de tokens (--spacing-control, --radius-control, --font-weight-control, --shadow-control). Redefina no @theme do site e todos os botões acompanham — veja a seção Personalização, no topo.
Uma classe na chamada substitui a do componente para a mesma propriedade: class="rounded-full h-14" tira o rounded-control e o h-control em vez de somar a eles.
Ui::button() define padrões (defaults), variantes e tamanhos novos, e classes por parte: base, content, icon e spinner. Um tamanho novo vale para o botão comum; o square segue a escala de tokens.
As variantes primary e secondary usam --color-primary e --color-secondary — a cor da marca, sem tom numerado. A tinta por cima é escura: branco sobre o roxo mede 4,12:1 e reprova, neutral-950 mede 4,89:1 e passa, então o preenchimento continua sendo a cor que o site escolheu.
Para texto sobre fundo claro existe text-primary-ink: a mesma cor numa luminosidade legível, derivada com oklch(from var(--color-primary) 0.45 c h). A cor da marca como texto mede 1,95:1 — passar o mouse num link deixava ele menos legível do que estava.
O ink é derivado, não escolhido: ele acompanha qualquer cor que o site defina. Medido em seis marcas bem diferentes, incluindo amarelo (1,57 → 7,43) e ciano (1,81 → 6,33).
Com href o elemento é <a>; sem, é <button> e o prop type passa a valer.
loading e disabled desligam o clique nos dois casos e marcam aria-disabled.
<x-ui.button href="https://goognet.com.br" external>Abre em nova aba</x-ui.button>
<x-ui.button type="submit" variant="primary">Enviar formulário</x-ui.button>
<x-ui.button type="reset" variant="ghost">Limpar</x-ui.button>
Badge
<x-ui.badge>
Etiqueta curta para estado, categoria ou contagem. Mesmos nomes de variante do x-ui.button; cor semântica sai por classe Tailwind no ponto de uso.
x-ui.badge
Prop
Padrão
variant
null
size
null
icon
null
icon-trailing
null
href
null
external
false
dot
false
rounded
null
Sem href é <span>; com, é <a> e ganha focus-visible. external sem href não emite target.
Uma classe passada substitui a do variant em vez de somar: duas utilidades de fundo no mesmo elemento são decididas pela ordem na folha de estilo, não pela ordem em que foram escritas. Vale para bg-, text- e border-, cada um por conta própria — class="bg-red-100" troca só o fundo e mantém o texto do variant.
Por isso não existe variante success/warning/danger: a cor semântica vem da paleta do Tailwind no ponto de uso, como no resto da lib.
dot usa bg-current, então o ponto acompanha a cor do texto, inclusive a sobrescrita.
rounded aceita um apelido (sm, md, base, lg, xl, full) ou uma utilidade inteira (rounded-none). Valor que não é nem um nem outro volta para a pílula, em vez de emitir uma classe que não estiliza nada.
Tabela de dados. Rola sozinha quando não cabe, em vez de empurrar a página para o lado.
x-ui.table
Prop
Padrão
headers
[]
rows
[]
caption
null
striped
false
size
null
A rolagem horizontal fica no invólucro da tabela, não na página: é a única exceção à regra de nunca deixar o corpo rolar para o lado.
headers aceita ['Plano'], ['plano' => 'Plano'] ou linhas com label, key e align. Com key, cada linha é lida por chave — uma coleção do banco entra sem mapear antes; sem key, é lida por posição.
O alinhamento é declarado uma vez, no cabeçalho, e vale para as células daquela coluna. Preço alinhado à direita com o cabeçalho à esquerda é o erro que isso evita.
Sem rows, o slot é usado como corpo — para quando uma célula precisa de markup, um badge ou um link.
caption vira <caption> de verdade: é o que um leitor de tela anuncia antes de entrar na tabela.
Cabeçalhos, alinhamento e zebra
Planos e preços
Plano
Inclui
Preço
Lite
Site institucional
R$ 90/mês
Pro
Site + blog + suporte
R$ 190/mês
Sob medida
Escopo fechado a cada projeto
sob consulta
blade
<x-ui.table
caption="Planos e preços"
striped
size="base"
:headers="['Plano', 'Inclui', ['label' => 'Preço', 'align' => 'end']]"
:rows="[
['Lite', 'Site institucional', 'R$ 90/mês'],
['Pro', 'Site + blog + suporte', 'R$ 190/mês'],
['Sob medida', 'Escopo fechado a cada projeto', 'sob consulta'],
]"
/>
Explicação curta presa a um gatilho. Sem JavaScript: abre no hover e no foco do teclado.
x-ui.tooltip
Prop
Padrão
text
null
placement
null
focusable
false
Abre no hover e no focus-within: um gatilho alcançado pelo teclado nunca recebe ponteiro, e uma dica que só o mouse abre é uma dica que metade dos visitantes não vê.
Quando o gatilho já é um botão ou um link, não é preciso mais nada. Em texto comum, focusable põe tabindex="0" e aria-describedby no invólucro, que é o que leva a dica ao teclado e ao leitor de tela.
Sem text, o componente rende só o gatilho — nada de bolha vazia numa página gerada por laço.
A bolha tem pointer-events-none: ela nunca fica entre o ponteiro e o que está embaixo.
Em volta de um controle
Copia o link desta páginaAbre no WhatsApp
blade
<x-ui.tooltip text="Copia o link desta página">
<x-ui.button size="sm" icon="heroicon-m-link">Copiar link</x-ui.button>
</x-ui.tooltip>
<x-ui.tooltip text="Abre no WhatsApp" placement="bottom">
<x-ui.button size="sm" variant="primary">Falar agora</x-ui.button>
</x-ui.tooltip>
Em texto, que não recebe foco sozinho
Prazo de
5 diasDias úteis, contados a partir da aprovação da arte.
left
À esquerda
right
À direita
blade
<x-ui.text>
Prazo de
<x-ui.tooltip text="Dias úteis, contados a partir da aprovação da arte." focusable placement="top">
<x-ui.text inline class="underline decoration-dotted">5 dias</x-ui.text>
</x-ui.tooltip>
</x-ui.text>
<x-ui.tooltip text="À esquerda" placement="left"><x-ui.badge>left</x-ui.badge></x-ui.tooltip>
<x-ui.tooltip text="À direita" placement="right"><x-ui.badge>right</x-ui.badge></x-ui.tooltip>
Counter
<x-ui.counter>
Número que conta até o valor quando entra na tela. O valor final é escrito pelo servidor, então a página sem JavaScript mostra o número certo.
x-ui.counter
Prop
Padrão
value
—
obrigatório
start
—
obrigatório
duration
2
decimals
—
obrigatório
prefix
''
suffix
''
separator
'.'
decimal
','
size
null
O número final é renderizado no servidor e o script conta a partir dele — sem JavaScript, e para um rastreador, a página mostra a figura real em vez de um zero esperando um script que nunca roda.
A animação começa quando o elemento entra na tela, uma vez só: contador que reinicia a cada rolagem lê como defeito.
Quem pede menos movimento no sistema (prefers-reduced-motion) recebe o número, sem contagem.
A biblioteca (countup.js) é importada só quando existe contador na página. Instale com npm install countup.js — o goognet-ui:install avisa quando falta.
decimals é limitado a 4: mais casas viram ruído, e o servidor e o script precisam concordar na formatação.
Bloco de conteúdo sobre uma superfície. Vira <a> sozinho quando recebe href, e só então ganha o movimento de hover.
x-ui.card
Prop
Padrão
href
null
external
false
variant
null
padding
null
O movimento de hover só existe quando o card leva a algum lugar: movimento promete clique.
A mídia é puxada para fora do espaçamento com margem negativa do tamanho do padding, então a imagem encosta na borda e acompanha o raio do topo.
Um href recusado pelo filtro de URL deixa o card como <div> — sem target nem rel sobrando, que seriam erro de validação.
Variantes
Padrão, com borda.
Elevated, com sombra.
Filled, sem borda.
Ghost, só o espaçamento.
blade
<x-ui.card class="max-w-xs">Padrão, com borda.</x-ui.card>
<x-ui.card variant="elevated" class="max-w-xs">Elevated, com sombra.</x-ui.card>
<x-ui.card variant="filled" class="max-w-xs">Filled, sem borda.</x-ui.card>
<x-ui.card variant="ghost" class="max-w-xs">Ghost, só o espaçamento.</x-ui.card>
Aviso curto que aparece depois de uma ação e some sozinho. Lê o que a requisição anterior deixou na sessão, então um formulário que deu certo não precisa de mais nada.
x-ui.toast
Prop
Padrão
message
null
title
null
type
null
position
null
duration
null
session
null
Sem mensagem nenhuma, o componente não rende nada — pode ficar no layout o tempo todo.
As chaves lidas da sessão, nesta ordem: toast, success, status, error, warning, info. Cada nome já traz o ícone que promete; session="minha-chave" lê só a sua, sem supor ícone.
O flash pode ser uma string ou um array com message, title e type.
A mensagem vai como texto para a caixa, nunca como HTML: um erro de validação ou um valor vindo do banco não vira markup na tela.
data-confirm em qualquer botão ou link pede confirmação antes de agir — o clique é segurado, a caixa responde, e só então a ação original acontece (formulário é enviado, o resto é clicado de novo).
A biblioteca (sweetalert2) é importada no primeiro uso: página sem toast e sem confirmação não paga por ela.
No layout, uma vez
blade
{{-- No <x-layouts.guest>, junto do rodapé: --}}
<x-ui.toast />
{{-- E no controller: --}}
{{-- return back()->with('success', 'Mensagem enviada. Respondemos no mesmo dia útil.'); --}}
<x-ui.button
variant="ghost"
icon="heroicon-m-trash"
data-confirm="Esta ação não pode ser desfeita."
data-confirm-title="Excluir o orçamento?"
data-confirm-action="Excluir"
>
Excluir
</x-ui.button>
Alert
<x-ui.alert>
Recado na página: confirmação, erro de formulário, aviso de manutenção. Neutro por padrão, colorido no ponto de uso.
x-ui.alert
Prop
Padrão
title
null
icon
null
variant
null
dismissible
false
live
false
Não existe variante success/danger aqui pelo mesmo motivo do badge: a cor semântica vem da paleta do Tailwind no ponto de uso, e a classe passada substitui a do variant em vez de somar.
live é o que acrescenta role="alert". Um aviso que já estava na página quando ela abriu não deve interromper a leitura; um que aparece depois do envio, sim.
dismissible marca o bloco com data-alert, e o initUi() cuida do resto — sem ele, nenhum listener é registrado.
Cor no ponto de uso
Mensagem enviada
Respondemos no mesmo dia útil.
Revise os campos marcados antes de enviar.
Atendimento em horário reduzido nesta sexta.
blade
<x-ui.alert icon="heroicon-m-check-circle" title="Mensagem enviada" live class="border-green-200 bg-green-50 text-green-800">
Respondemos no mesmo dia útil.
</x-ui.alert>
<x-ui.alert icon="heroicon-m-exclamation-triangle" class="border-amber-200 bg-amber-50 text-amber-900">
Revise os campos marcados antes de enviar.
</x-ui.alert>
<x-ui.alert icon="heroicon-m-information-circle" variant="filled" dismissible>
Atendimento em horário reduzido nesta sexta.
</x-ui.alert>
Variantes
Padrão, com borda.
Ghost, sem fundo.
blade
<x-ui.alert>Padrão, com borda.</x-ui.alert>
<x-ui.alert variant="ghost">Ghost, sem fundo.</x-ui.alert>
Checkbox
<x-ui.checkbox>
Caixa de marcação com rótulo, dica e erro, no mesmo desenho dos demais campos.
x-ui.checkbox
Prop
Padrão
name
null
id
null
value
'1'
label
null
hint
null
error
null
checked
false
required
false
O id sai de name + value, então várias caixas do mesmo campo convivem sem uma roubar o clique da outra.
A cor vem de accent-primary, no controle nativo: sem SVG substituto, o estado marcado continua sendo o do sistema.
Aceite e opções
No máximo um e-mail por mês.
É preciso aceitar para continuar.
blade
<x-ui.checkbox name="aceite" label="Li e aceito a política de privacidade" required />
<x-ui.checkbox name="novidades" value="sim" label="Quero receber novidades" hint="No máximo um e-mail por mês." checked />
<x-ui.checkbox name="termos" id="termos-2024" label="Contrato de 2024" error="É preciso aceitar para continuar." />
Radio
<x-ui.radio>
Escolha única. Mesmo desenho do checkbox, com o value obrigatório — é ele que diz o que a opção envia.
x-ui.radio
Prop
Padrão
name
null
id
null
value
—
obrigatório
label
null
hint
null
error
null
checked
false
required
false
Um radio sem value enviaria on em qualquer opção do grupo, então o componente exige o valor em vez de escolher um por você.
Depois de um envio recusado, volta marcada a opção que tinha sido escolhida.
Menu de ações preso a um gatilho. Usa o mesmo script do menu, então não traz JavaScript próprio.
x-ui.dropdown
Prop
Padrão
id
null
label
null
icon
null
align
null
width
null
variant
null
size
null
O painel é irmão do gatilho dentro de um [data-menu], que é o que o script do menu observa: abrir, fechar no clique fora, no Esc e andar com as setas já vêm de lá.
Cada dropdown gera o próprio id, então vários na mesma página não se confundem.
Com x-slot:trigger, o gatilho é seu — passe id no dropdown e repita esse mesmo valor no aria-controls do gatilho: é por ele que o script encontra o painel. Mantenha também data-menu-dropdown, data-state e aria-expanded, que completam o contrato.
Campo de texto com rótulo, dica e erro. Lê sozinho a mensagem que a validação deixou e devolve o que foi digitado no envio anterior.
x-ui.input
Prop
Padrão
name
null
id
null
type
'text'
label
null
hint
null
error
null
icon
null
value
null
mask
null
size
null
required
false
control-class
null
Sem error, a mensagem vem do $errors da própria requisição — name="items[0][qty]" é procurado como items.0.qty, que é como o validator guarda. Uma mensagem passada na chamada vence a do validator.
O erro nunca é só a borda vermelha: entra aria-invalid, a mensagem ganha role="alert" e o campo aponta para ela por aria-describedby.
O campo volta preenchido com o envio anterior, exceto quando é type="password" — repopular devolveria a senha digitada para dentro do HTML.
type aceita só os tipos de campo de texto. file, submit, image ou checkbox virariam outro controle dentro de um rótulo que promete texto, então voltam para text.
class veste o bloco inteiro (rótulo, campo e mensagem); control-class veste só o campo.
mask aceita um nome pronto — phone, cpf, cnpj, cpf-cnpj, cep, date, time, money, percent, card — ou um padrão escrito, onde 0 é dígito e a é letra.
Junto da máscara vai o inputmode: no celular, campo de dígitos abre o teclado numérico em vez do alfabético.
O padrão passado é filtrado antes de virar atributo, então um valor vindo do banco ou da query string não consegue injetar markup ali.
phone e cpf-cnpj aceitam os dois comprimentos: a máscara acompanha o que está sendo digitado.
A biblioteca (imask) é importada só quando existe campo com máscara na página. Instale com npm install imask.
Rótulo, dica e obrigatório
Usamos só para responder.
blade
<x-ui.input name="nome" label="Nome" placeholder="Como podemos te chamar?" required />
<x-ui.input name="email" type="email" label="E-mail" hint="Usamos só para responder." />
<x-ui.input name="telefone" type="tel" label="Telefone" icon="heroicon-m-phone" />
Campo de texto longo. Mesmo rótulo, dica e erro do input, e cresce com o que é digitado.
x-ui.textarea
Prop
Padrão
name
null
id
null
label
null
hint
null
error
null
rows
4
size
null
required
false
control-class
null
field-sizing-content faz a caixa acompanhar o texto; rows continua valendo como altura inicial.
O conteúdo sai do slot; sem slot, volta o que foi enviado da última vez.
Mensagem
Opcional.
Passou de 500 caracteres.
blade
<x-ui.textarea name="mensagem" label="Mensagem" rows="5" placeholder="Conte o que você precisa" />
<x-ui.textarea name="obs" id="observacoes" label="Observações" hint="Opcional." error="Passou de 500 caracteres." />
<x-ui.textarea name="resumo" label="Resumo" size="sm" required control-class="font-mono" />
Select
<x-ui.select>
Lista de opções. Aceita options como mapa, como lista ou como linhas vindas do banco.
x-ui.select
Prop
Padrão
name
null
id
null
label
null
hint
null
error
null
options
[]
selected
null
placeholder
null
size
null
required
false
control-class
null
:options="['sp' => 'São Paulo']", :options="['São Paulo']" e linhas com value/label (ou id/name) chegam todos na mesma forma, então uma coleção do banco entra sem mapear antes.
placeholder vira uma opção de valor vazio no topo, marcada enquanto nada foi escolhido — com required, é ela que faz o navegador cobrar a escolha.
A seta é um SVG por cima com pointer-events-none: o clique continua abrindo a lista nativa.
Opções e placeholder
Responde quem cuida do assunto.
Escolha um estado.
blade
<x-ui.select
name="assunto"
label="Assunto"
placeholder="Escolha um assunto"
:options="['orcamento' => 'Orçamento', 'suporte' => 'Suporte', 'outro' => 'Outro']"
selected="suporte"
hint="Responde quem cuida do assunto."
required
/>
<x-ui.select
name="uf"
id="estado"
label="Estado"
size="sm"
control-class="font-mono"
error="Escolha um estado."
:options="['sp' => 'São Paulo', 'rj' => 'Rio de Janeiro']"
/>
Field
<x-ui.field>
O invólucro que o input, o textarea e o select usam por dentro: rótulo, dica e mensagem de erro amarrados ao controle.
x-ui.field
Prop
Padrão
id
null
name
null
label
null
hint
null
error
null
required
false
Serve para o controle que a lib não cobre — um file, um campo de terceiro — sem perder o rótulo, a dica e o erro no mesmo desenho dos demais.
O id é quem amarra tudo: for no rótulo, -hint e -error no aria-describedby do controle.
Em volta de um controle próprio
PDF de até 5 MB.
Envie o arquivo em PDF.
blade
<x-ui.field id="arquivo" label="Currículo" hint="PDF de até 5 MB." required error="Envie o arquivo em PDF.">
<input id="arquivo" type="file" name="curriculo" class="text-sm text-neutral-700" />
</x-ui.field>
Footer
<x-ui.footer>
Rodapé do site: faixa de chamada, colunas de navegação e contato, e a linha legal. Tudo alimentado pelo config/goognet-ui.php.
x-ui.footer
Prop
Padrão
callout
true
callout-title
'Precisa de um orçamento?'
callout-text
'Resposta no mesmo dia útil.'
callout-action
'Solicite um orçamento'
description
null
validator
true
A faixa de chamada vem antes dos links: quem chegou ao fim está perguntando o que fazer agora. Os textos são props (callout-title, callout-text, callout-action) e :callout="false" tira a faixa — numa política de privacidade, por exemplo. :validator="false" tira o selo do W3C.
Nada é escrito à mão: navegação de goognet-ui.menu, redes de goognet-ui.social, contatos e nome de goognet-ui.company, assinatura de goognet-ui.agency. Coluna sem dado não é renderizada, em vez de sair vazia.
A assinatura sai de goognet-ui.agency, e o slot credit a substitui quando o crédito é um logo, outra frase ou nada disso. Sem nome na config e sem slot, a linha inteira não é renderizada.
O slot padrão vira mais uma coluna na grade — CNPJ, endereço, selo. Conteúdo mais largo se resolve no próprio bloco, com sm:col-span-2.
A marca ocupa uma faixa própria, acima de três colunas de largura igual. Como primeira coluna ela ficava com 473px para 280px de conteúdo — 233px de vão morto ao lado, porque foi dimensionada supondo uma descrição que o boilerplate não traz preenchida.
O link da política aparece em Institucional, e é descartado dali se o goognet-ui.menu já o listar: um site que o punha na navegação principal mostrava o mesmo link duas vezes no rodapé.
O botão da faixa é uma <a> vestida de botão, não um <button> dentro de <a> — conteúdo interativo aninhado é HTML inválido, e o validador do W3C acusa.
O link para a política só aparece se a rota privacy existir, então o rodapé não quebra num site que ainda não tem a página.
Os alvos das redes sociais são de 44px no mobile e 40px de sm para cima.
O bloco legal são duas linhas, cada uma abrindo com o seu filete: copyright e voltar ao topo na primeira; selo e crédito da agência na segunda, cada um numa ponta. Numa linha só, agrupadas, elas liam como um bloco solto num canto.
O botão flutuante do WhatsApp é fixo a 12px do canto com 64px, e a última linha é o fim da página. A folga vai embaixo (pb-24), não reservada à direita: reservar largura fazia a barra terminar antes das colunas de cima, e era o desalinhamento visível. Medido em 1280px: sem sobreposição, 22px entre a última linha e o botão.
Voltar ao topo usa href="#", o fragmento vazio: leva ao início do documento. O smooth-anchors.js suaviza e mantém a âncora fora da barra de endereço; sem o script, continua funcionando, só que instantâneo.
O selo do W3C Validator aponta para a página em que está, não para a raiz do site: url()->current(), que já descarta a query string — parâmetro de rastreio não faz parte do que se valida e quebraria a busca do validador. O rel leva nofollow, porque selo de saída não deve passar ranking.
Completo
blade
<x-ui.footer />
Assinatura própria e uma coluna a mais
blade
<x-ui.footer :callout="false">
<div class="sm:col-span-2 lg:col-span-1">
<x-ui.text size="sm" class="text-neutral-500">
CNPJ 00.000.000/0001-00 — Av. Paulista, 1000, São Paulo/SP
</x-ui.text>
</div>
<x-slot:credit>
<x-ui.text size="sm">
Feito por
<x-ui.link href="https://goognet.com.br" external underline="hover">Goognet</x-ui.link>
</x-ui.text>
</x-slot:credit>
</x-ui.footer>
Chamada com outro texto e sem selo
blade
<x-ui.footer
callout-title="Vamos conversar sobre o seu projeto?"
callout-text="Atendemos de segunda a sexta."
callout-action="Falar no WhatsApp"
:validator="false"
/>
Sem a faixa de chamada
blade
<x-ui.footer :callout="false" />
Com descrição própria
blade
<x-ui.footer description="Corte a laser e dobra de chapas sob medida." :callout="false" />
Image
<x-ui.image>
Imagem responsiva. O plugin images() do vite.config.jsdo site corta cada jpg/png em 400/800/1200/1600 e em webp; o componente monta o <picture> a partir do que existe no disco.
x-ui.image
Prop
Padrão
src
—
obrigatório
alt
''
widths
null
sizes
'100vw'
eager
false
As prévias deste catálogo usam picsum.photos, e os dois últimos exemplos não têm prévia: o boilerplate é um template e não carrega foto de exemplo no disco. Como URL externa não tem cópias para escolher, ela sai como <img> simples — ou seja, a prévia acima não mostra o <picture>. Aponte o src para um jpg/png seu em resources/images para ver o srcset montado.
Nome sem barra vive em resources/images; com barra, o caminho vai como veio. SVG, URL externa e arquivo sem cópias no disco saem como <img> simples, sem <picture>.
As larguras do srcset vêm do que está no disco, não de uma lista fixa: o plugin não amplia imagem, então fonte de 900px gera só 400 e 800. O prop widths apenas restringe esse conjunto.
width e height saem de getimagesize no arquivo de origem — é o que reserva a caixa e evita o salto de layout. Arquivo ausente, sem medida: o componente cai no <img> simples em vez de emitir um <source> quebrado.
Padrão é loading="lazy". Use eager só na imagem que é candidata a LCP: ela liga fetchpriority="high", que perde o sentido se estiver em todas.
sizes é 100vw por padrão. Se a imagem não ocupa a largura toda, informe — o navegador escolhe o candidato por esse valor, não pelo CSS.
Compressão: webp em quality: 75, effort: 6 e o formato original em quality: 80 com mozjpeg. São escalas diferentes — webp 80 sai maior que mozjpeg 80 na mesma foto, o que faria o navegador preferir o arquivo mais pesado pelo <source>. Medido numa foto de 2400px cortada em 1200: mozjpeg 80 = 168,6 kB, webp 80 = 178,6 kB, webp 75 = 140,0 kB.
background-image no CSS funciona sem build: com npm run dev, o plugin processa a pasta ao subir e gera as cópias de arquivo novo em ~2s. Referência para arquivo que não existe em resources/imagesquebra o build de propósito — o padrão do Vite é só avisar e deixar o caminho quebrado ir para produção.
As cópias com sufixo de largura são geradas e estão no .gitignore. O .webp em tamanho cheio continua versionado, porque um .webp pode ser arquivo de origem.
O x-ui.brand tem a sua própria regra de webp: para jpg/png local ele emite o <source> sem conferir o disco. URL externa não recebe <picture> — não há irmão neste disco para apontar. São contratos diferentes, de propósito.
Nota em estrelas. Sem name exibe um número; com name vira campo de formulário, com hover e seleção só em CSS.
x-ui.rating
Prop
Padrão
name
null
value
null
max
5
size
null
shape
null
label
null
clearable
false
disabled
false
max tem teto de 10. Cada ponto vira um SVG renderizado no servidor, e um valor sem limite derrubava a página.
A presença de name é o que decide: sem ele sai um <span role="img"> com aria-label; com ele sai um <fieldset> de radios, navegável pelo teclado como qualquer grupo de radio.
Na exibição, o preenchimento é uma camada recortada por porcentagem, não meia estrela: :value="4.3" desenha 86% e lê como 4,3. Valor fora da escala é grampeado nas pontas.
No campo, as estrelas estão no HTML de max para 1 e são reviradas com flex-row-reverse. É isso que faz o CSS puro funcionar: um input marcado só alcança os irmãos seguintes, então as estrelas menores precisam vir depois dele.
Os estados moram em [data-rating] no ui.css, junto do tema do Swiper, e não em utilitárias: a prévia do hover precisa vencer a seleção atual, e utilitária sai na ordem do framework, não na ordem em que foi escrita no elemento.
clearable acrescenta um radio de valor vazio, para limpar a nota enviar o campo em branco em vez de sumir do payload.
Cada grupo gera ids próprios, então dois ratings convivem na mesma página sem um roubar o clique do outro.
Título. O level decide a semântica, o size decide o tamanho — os dois são separados de propósito.
x-ui.heading
Prop
Padrão
size
null
level
null
Sem level ele rende <div>, não <h?>. É deliberado: título de card que não é subdivisão do documento não deve entrar no sumário que o leitor de tela percorre. Quando for seção de verdade, passe :level="2".
Tamanho e nível são separados: uma h2 pode ser pequena e um rótulo pode ser grande. Amarrar os dois obrigaria a página a escolher entre o sumário certo e a proporção certa.
A escala é a que as páginas já usavam — 2xl é o título da política, xl o de seção, lg o do modal, base um rótulo.
Cor vem por classe: class="text-primary-ink". O componente não tem prop de cor, pela mesma razão do x-ui.badge — cor semântica se escreve onde tem significado.
Vale a regra de .ai/rules/views.md: uma h1 por página, e <section> abre com <header> em volta do título.
Os quatro tamanhos
Rótulo de campo
Título de card
Título de seção
Título da página
blade
<x-ui.heading size="base">Rótulo de campo</x-ui.heading>
<x-ui.heading size="lg">Título de card</x-ui.heading>
<x-ui.heading size="xl">Título de seção</x-ui.heading>
<x-ui.heading size="2xl">Título da página</x-ui.heading>
Com nível, entra no sumário da página
Seção de verdade
blade
<x-ui.heading :level="2" size="xl">Seção de verdade</x-ui.heading>
Cor no ponto de uso
Destaque da marca
blade
<x-ui.heading size="xl" class="text-primary-ink">Destaque da marca</x-ui.heading>
Text
<x-ui.text>
Texto de corpo. Rende <p>, ou <span> com inline quando está dentro de uma frase.
x-ui.text
Prop
Padrão
size
null
variant
null
inline
false
A cor padrão só é aplicada se a classe não trouxer outra. Sem isso, class="text-blue-700" perdia para o cinza do componente: no CSS gerado, blue vem antes de neutral, e vence quem vem depois na folha.
Com inline vira <span>, e perde leading-relaxed e text-pretty: espaçamento de linha e rebalanceamento das últimas linhas são de bloco, não de trecho.
O variant é tom, não cor. O Flux oferece dezessete nomes de paleta aqui; esta lib mantém cor no ponto de uso — class="text-red-700" — para não virar uma lista de cores a manter.
O subtle é neutral-600 e não um cinza mais claro: a 16px sobre branco ele mede 7,56:1, enquanto neutral-400 mede 2,6 e reprova nos 4,5:1 que corpo de texto deve.
Tamanhos
Pequeno, para apoio.
Padrão, para corpo de texto.
Maior, para abertura de página.
Destaque.
blade
<x-ui.text size="sm">Pequeno, para apoio.</x-ui.text>
<x-ui.text>Padrão, para corpo de texto.</x-ui.text>
<x-ui.text size="lg">Maior, para abertura de página.</x-ui.text>
<x-ui.text size="xl">Destaque.</x-ui.text>
Tom
Texto forte, para o que precisa pesar.
Texto padrão.
Texto discreto, para apoio.
blade
<x-ui.text variant="strong">Texto forte, para o que precisa pesar.</x-ui.text>
<x-ui.text>Texto padrão.</x-ui.text>
<x-ui.text variant="subtle">Texto discreto, para apoio.</x-ui.text>
Dentro de uma frase
O prazo é de
cinco dias úteis
a partir da confirmação.
blade
<x-ui.text>
O prazo é de
<x-ui.text variant="strong" inline>cinco dias úteis</x-ui.text>
a partir da confirmação.
</x-ui.text>
Link
<x-ui.link>
Âncora de texto. A cor de repouso é herdada do contexto; a variante pinta só o hover, para o mesmo link servir em fundo claro e escuro.
x-ui.link
Prop
Padrão
href
null
variant
null
underline
null
size
null
icon
null
icon-trailing
null
external
false
label
null
Todo href passa por SafeUrl: aceita http, https, mailto, tel, âncora e caminho relativo. javascript: e afins — inclusive com tab ou quebra de linha no meio — são descartados e o link fica sem destino. O mesmo filtro vale para formaction, xlink:href e demais atributos repassados.
Sem conteúdo no slot o link vira só ícone: o sublinhado some e o label entra como texto de leitor de tela.
external (ou target="_blank") já acrescenta rel="noopener noreferrer".
Logo do site, com nome opcional ao lado. O logo é o arquivo que você quer — sem convenção de nome — ou markup pelo slot de mesmo nome.
x-ui.brand
Prop
Padrão
logo
null
name
null
alt
null
href
null
external
false
Sem logo ele usa goognet-ui.company.logo — o mesmo nome que alimenta o logo do JSON-LD, num lugar só, para o cabeçalho, o rodapé e o schema não divergirem. O boilerplate deixa essa config vazia e não versiona logo nenhum: sem arquivo, o componente rende o nome da empresa como letreiro, em vez de quebrar a página num caminho que não existe.
O logo aponta o arquivo direto: logo="minha-marca.svg". Nome sem barra procura em resources/images; com barra, vale como está. URL (https://, // ou data:) vai para o src como veio — é o que os exemplos acima usam, para o template não carregar logo de exemplo. Não existe sufixo nem variante a decorar.
O logo é prop e slot, como no Flux. Escrito como atributo é caminho de arquivo; como <x-slot:logo> é markup — SVG inline, ícone, letra. Se vierem os dois, o slot vence.
Com name o alt da imagem vira vazio. A palavra já está na tela; um alt repetindo faz o leitor de tela anunciar a empresa duas vezes seguidas. alt explícito continua valendo, para marca que diz algo que o nome não diz.
O slot logo substitui a imagem por completo: SVG inline, ícone ou letra. As classes do slot vão para a caixa dele, então quem chama controla tamanho e cor.
O nome não tem tamanho de fonte próprio — herda o do texto em volta. O mesmo componente lê certo numa barra de 14px e num rodapé de 18px sem prop para isso.
Aviso de cookies. Não bloqueia nada: registra que o visitante foi informado e some. Renderizado no servidor, então quem já aceitou nunca recebe o markup.
x-ui.cookie-consent
Prop
Padrão
policy
null
name
null
A decisão de exibir é feita no PHP, lendo o cookie. Esconder por JavaScript faria o aviso piscar em toda página, antes do script rodar.
O cookie é escrito pelo navegador em texto puro e lido no Blade, então precisa ficar fora da criptografia de cookies do Laravel. O pacote registra essa exceção sozinho, pelo nome em goognet-ui.cookie_consent.name. Por isso o nome se troca no config: com a prop name diferente, a exceção não acompanha e o banner não some.
Nada é bloqueado antes do aceite: o clique só grava cookie_consent=accepted por um ano, em Path=/ e SameSite=Lax, e remove o card.
Tem duas formas. Em tela estreita é uma barra colada no rodapé, em largura total: um card flutuando cem pixels acima do fundo lê como sobra de layout. De sm para cima vira card no canto inferior esquerdo, longe do botão flutuante do WhatsApp.
O botão do WhatsApp sobe pela altura real da barra, não por um valor fixo: o script publica --cookie-consent-height com um ResizeObserver, e o ui.css usa isso dentro de body:has([data-cookie-consent]). O texto quebra em mais linhas em telas menores, então um deslocamento fixo erraria. Medido em 390px: barra de 183px, botão 12px acima dela; ao aceitar, a variável é removida e o botão volta para 12px do fundo.
O empurrão depende de data-floating no elemento fixo. Outro botão flutuante que precise do mesmo tratamento é só marcar igual.
O link "Saber mais" aponta para a rota privacy quando ela existe, e some quando não existe. Passe policy para apontar para outro lugar.
Nos exemplos acima o static! tira o card do fixed só para ele aparecer dentro do catálogo.
Padrão
Usamos apenas
cookies essenciais
para o funcionamento do site. Ao continuar navegando, você concorda com a nossa política de
privacidade.
Este site usa cookies para medir audiência. Ao continuar, você concorda com a política de privacidade.
blade
<x-ui.cookie-consent name="aviso_lgpd" class="static! w-full max-w-md shadow-none ring-1 ring-neutral-200">
Este site usa cookies para medir audiência. Ao continuar, você concorda com a política de privacidade.
</x-ui.cookie-consent>
Apontando para outra política
Usamos apenas
cookies essenciais
para o funcionamento do site. Ao continuar navegando, você concorda com a nossa política de
privacidade.
Navegação principal. No desktop abre dropdown ou megamenu; abaixo de lg vira hambúrguer com gaveta e acordeão.
x-ui.menu
Prop
Padrão
items
null
label
'Menu principal'
Cada item aceita url (caminho literal) ou route (nome da rota). Prefira route: caminho literal em config/goognet-ui.php precisa ser lembrado em dois lugares, e se você mudar a rota o menu continua apontando para o endereço velho sem avisar.
O route não pode ser resolvido dentro do config/goognet-ui.php — config é lido no bootstrap, antes de o roteador existir, e route() ali morre com Argument #2 ($request) must be of type Request, null given. Com config:cache seria pior: a URL ficaria congelada com o domínio da máquina que rodou o comando. Por isso o componente guarda o nome e resolve na renderização.
Com parâmetro: ['route' => ['posts.show', ['slug' => 'meu-post']]]. Rota inexistente estoura dizendo qual nome e qual item — funciona dentro de dropdown e megamenu também.
O item atual acende também nas páginas abaixo dele: em /blog/meu-artigo o item Blog continua marcado. Casamento exato sozinho deixava toda página de artigo com a barra inteira apagada.
A barra final no prefixo é o que impede /blog de roubar /blog-antigo. E a home nunca entra como prefixo: todo endereço do site começa nela, então ela acenderia em tudo.
Passe current no item para decidir à mão — true para uma landing que pertence a uma seção sem estar abaixo dela, false para apagar uma seção numa página dela mesma.
O badge aceita string ou array com cor: ['label' => '2', 'variant' => 'primary']. Vai para o x-ui.badge, então o vocabulário é o mesmo do resto da lib, e funciona tanto em link quanto em gatilho de dropdown.
A gaveta do celular marca a página atual como linha preenchida, não como traço. Antes ela não marcava nada, e no telefone o menu nunca dizia onde a pessoa estava.
Um item com children vira dropdown; com groups vira megamenu. Sem os dois, é link simples.
O slot padrão aparece só no rodapé da gaveta mobile — é onde mora o call-to-action.
Painel largo de navegação, com grupos em colunas. Vive dentro do x-ui.menu quando um item traz groups, e existe solto para quem monta o header à mão.
x-ui.megamenu
Prop
Padrão
label
null
groups
[]
columns
null
x-ui.megamenu-panel
Prop
Padrão
groups
[]
columns
null
O painel se estende sobre o ancestral posicionado mais próximo, então quem o usa solto precisa de um relative em volta — no x-ui.navbar isso já vem pronto.
columns aceita 1 a 4 e as classes estão escritas por extenso no componente: nome de classe interpolado nunca entra na folha de estilo do Tailwind.
Cada filho aceita icon e description. Sem descrição o item vira uma linha simples, e a lista continua legível.
Abaixo de lg o painel não flutua: cai no fluxo, que é o que o x-ui.menu usa para achatá-lo dentro da gaveta.
O slot fecha o painel com uma chamada — no header costuma ser o botão de orçamento.
Sanfona sobre <details>/<summary>: o navegador já dá semântica de disclosure, Esc, Enter e busca na página. O atalho faq emite o JSON-LD de FAQPage.
x-ui.accordion
Prop
Padrão
name
null
label
null
faq
[]
x-ui.accordion-item
Prop
Padrão
name
herdado do pai
null
label
—
obrigatório
icon
null
open
false
Com name no grupo, abrir um item fecha o outro — é o name nativo do <details>. Sem ele, cada item é independente.
A altura anima por ::details-content com interpolate-size. Navegador sem suporte abre seco, sem quebrar nada.
O atalho faq emite o JSON-LD de FAQPage junto. A resposta é texto puro e sai escapada, porque o Google recusa markup dentro de acceptedAnswer. Resposta com HTML vai pelo slot, que não emite schema.
FAQ com JSON-LD
Qual o prazo de entrega?
De 3 a 5 dias úteis para todo o Brasil.
Posso parcelar?
Em até 12x sem juros no cartão.
Tem garantia?
12 meses de garantia de fábrica.
blade
<x-ui.accordion name="docs-faq-schema" label="Perguntas frequentes" :faq="[
'Qual o prazo de entrega?' => 'De 3 a 5 dias úteis para todo o Brasil.',
'Posso parcelar?' => 'Em até 12x sem juros no cartão.',
'Tem garantia?' => '12 meses de garantia de fábrica.',
]" />
Exclusivo, com um item aberto
Qual o prazo de entrega?
De 3 a 5 dias úteis para todo o Brasil.
Posso parcelar?
Em até 12x sem juros no cartão.
Tem garantia?
12 meses de garantia de fábrica.
blade
<x-ui.accordion name="docs-faq" label="Perguntas frequentes">
<x-ui.accordion-item label="Qual o prazo de entrega?" open>
<p>De 3 a 5 dias úteis para todo o Brasil.</p>
</x-ui.accordion-item>
<x-ui.accordion-item label="Posso parcelar?" icon="heroicon-m-credit-card">
<p>Em até 12x sem juros no cartão.</p>
</x-ui.accordion-item>
<x-ui.accordion-item label="Tem garantia?">
<p>12 meses de garantia de fábrica.</p>
</x-ui.accordion-item>
</x-ui.accordion>
Modal
<x-ui.modal>
Diálogo sobre <dialog> nativo: foco preso, Esc, fundo inerte e top layer vêm do navegador. O script só roteia os cliques.
x-ui.modal
Prop
Padrão
name
null
title
null
size
null
closable
true
Abre com data-modal-open="nome" em qualquer elemento da página; fecha com data-modal-close dentro do modal.
O scroll da página trava enquanto houver modal aberto e volta quando o último fecha.
Sem title e com :closable="false", o cabeçalho inteiro deixa de existir.
Gatilho, corpo e rodapé
blade
<x-ui.button variant="primary" data-modal-open="docs-orcamento">Pedir orçamento</x-ui.button>
<x-ui.modal name="docs-orcamento" title="Peça um orçamento">
<p>Conte o que você precisa e respondemos em até 1 dia útil.</p>
<x-slot:footer>
<x-ui.button variant="ghost" data-modal-close>Cancelar</x-ui.button>
<x-ui.button variant="primary">Enviar</x-ui.button>
</x-slot>
</x-ui.modal>
Travado: só fecha pelo botão
blade
<x-ui.button data-modal-open="docs-aviso">Abrir aviso travado</x-ui.button>
<x-ui.modal name="docs-aviso" title="Confirme antes de sair" :closable="false" size="sm">
<p>Esse não fecha no Esc nem no clique de fora.</p>
<x-slot:footer>
<x-ui.button variant="primary" data-modal-close>Entendi</x-ui.button>
</x-slot>
</x-ui.modal>
Carousel
<x-ui.carousel>
<x-ui.carousel-slide>
Carrossel sobre o Swiper, com lightbox opcional via fslightbox. A configuração vai inteira num data-carousel e o script a lê por instância, então várias galerias convivem na mesma página.
x-ui.carousel
Prop
Padrão
per-view
1
gap
16
autoplay
false
auto-height
false
loop
null
pagination
false
dynamic-bullets
false
navigation
false
lightbox
false
label
'Carrossel'
x-ui.carousel-slide
Prop
Padrão
lightbox
herdado do pai
false
source
null
type
null
perView e gap aceitam valor único ou mapa por breakpoint do Tailwind (base, sm, md, lg, xl, 2xl).
O loop só liga quando há ao menos per-view + 1 slides no maior breakpoint — a mesma conta que o Swiper faz. Abaixo disso ele não funciona e o Swiper avisa no console, então o pacote desliga em silêncio, inclusive com :loop="true". :loop="false" desliga sempre. Testado contra o Swiper em 48 combinações de slides e per-view: nenhum aviso e nenhum loop desligado sem necessidade.
O Swiper 12 não tem mais a opção lazy. Imagem preguiçosa é loading="lazy" no próprio <img>.
Autoplay pausa no hover pelo pauseOnMouseEnter do Swiper, e não liga quando o sistema pede prefers-reduced-motion: reduce.
auto-height faz a caixa acompanhar a altura do slide em exibição, em vez de todos dividirem a altura do mais alto. Vale para conteúdo de tamanho desigual — depoimento de duas linhas ao lado de um de dez. Numa grade de cartões deixe desligado: ali a altura uniforme é o que alinha a fileira.
Com auto-height e per-view maior que 1, a altura é a do slide mais alto entre os visíveis, não a do ativo.
A altura é animada pelo CSS do próprio Swiper. Sob prefers-reduced-motion: reduce o ui.css tira essa transição: a caixa muda de tamanho, mas sem percorrer o caminho.
A paginação fica fora do .swiper de propósito: o Swiper só posiciona bullets que são filhos diretos do container, e manter fora dispensa !important.
Os bullets têm área de clique de 24px (mínimo da WCAG 2.2), com o ponto visível de 12px desenhado dentro.
dynamic-bullets mostra cinco pontos por vez, encolhendo os das pontas, em vez de uma fileira que cresce sem fim. Vale a partir de umas oito imagens; com poucas, só tira a noção de quantas são.
O dynamicBullets do Swiper escala o próprio bullet — que aqui é o alvo de clique — e os 0,33 dele deixariam um alvo de 8px. O ui.css cancela esse transform e aplica a escala ao ponto: a faixa fica como o Swiper desenha e o alvo continua de 24px.
Pedir dynamic-bullets já liga a paginação. Escrever os dois não é erro, mas escrever só dynamic-bullets também funciona — não existe o caso de pedir e não aparecer nada.
O lightbox usa o pacote fslightbox e só entra na página que tem carrossel com ele: o import é dinâmico, num chunk à parte.
Cada slide precisa do prop source com a imagem grande — o thumb fica no slot. Slide sem source continua slide comum, sem clique.
O valor de lightbox é o nome da galeria e chega ao slide por @aware. Dois carrosséis com o mesmo nome viram uma galeria só; lightbox sem valor usa o nome carousel para todos.
O fsLightbox é carregado antes do Swiper de propósito: ele varre o DOM na hora que entra e guarda a ordem que encontrou, e o loop do Swiper move os slides de lugar depois disso.
Vídeo ou fonte que a extensão não denuncia: passe type no slide (image, video, youtube).
Chegamos com o prazo em cima e mesmo assim refizeram o orçamento no mesmo dia.
A equipe montou tudo em duas visitas, deixou o local limpo e ainda voltou na
semana seguinte para conferir o acabamento. É raro encontrar esse cuidado
depois que a nota já foi emitida.
Caio Menezes
blade
<x-ui.carousel label="Depoimentos" :gap="24" auto-height navigation pagination class="px-14">
<x-ui.carousel-slide>
<figure class="shadow-soft rounded-xl border border-neutral-200 bg-white p-6">
<blockquote class="text-neutral-700">Resolveram em um dia.</blockquote>
<figcaption class="mt-3 text-sm text-neutral-500">Ana Prado</figcaption>
</figure>
</x-ui.carousel-slide>
<x-ui.carousel-slide>
<figure class="shadow-soft rounded-xl border border-neutral-200 bg-white p-6">
<blockquote class="text-neutral-700">
Chegamos com o prazo em cima e mesmo assim refizeram o orçamento no mesmo dia.
A equipe montou tudo em duas visitas, deixou o local limpo e ainda voltou na
semana seguinte para conferir o acabamento. É raro encontrar esse cuidado
depois que a nota já foi emitida.
</blockquote>
<figcaption class="mt-3 text-sm text-neutral-500">Caio Menezes</figcaption>
</figure>
</x-ui.carousel-slide>
</x-ui.carousel>
Grade de imagens com lightbox opcional. Mesma gramática do carousel — lightbox no pai, source no item — mas sem trilho: tudo aparece de uma vez.
x-ui.gallery
Prop
Padrão
columns
['base' => 2, 'md' => 3]
gap
4
lightbox
false
label
null
masonry
false
x-ui.gallery-item
Prop
Padrão
lightbox
herdado do pai
false, 'masonry' => false, 'gap' => 4
src
null
source
null
alt
''
type
null
eager
false
sizes
'(min-width: 768px) 33vw, 50vw'
type só aceita image, video ou youtube. Qualquer outro valor é descartado.
masonry troca a grade por colunas CSS: cada imagem fica com a altura que tem, em vez de ser recortada na altura da linha. O preço é a ordem de leitura — coluna desce antes de virar, então o segundo item fica embaixo do primeiro, não ao lado.
Na mansonry o espaço entre imagens empilhadas é a margem do próprio item (o gap de coluna não separa linhas), e break-inside-avoid impede que uma imagem seja cortada no pé da coluna.
A galeria é <ul> e o item é <li>: leitor de tela anuncia quantas imagens são. O label vira aria-label e é opcional.
columns aceita número ou mapa por breakpoint do Tailwind, de 1 a 6. gap aceita 2, 3, 4, 5, 6, 8, 10 ou 12 — os valores estão escritos por extenso no componente porque o scanner do Tailwind não enxerga classe montada por interpolação.
Sem source, o próprio src abre no lightbox. Informe source quando existir uma versão maior — é o caso normal: o thumb não precisa ter 1600px.
Item sem src e sem source continua item comum, sem clique. É assim que o slot livre convive com a grade clicável.
O source não precisa ser imagem: com type="youtube" o item vira capa de vídeo dentro da mesma galeria.
eager e sizes vão direto para o x-ui.image. Use eager só na imagem que aparece sem rolar a página.
O nome em lightbox agrupa a galeria. Duas galerias com o mesmo nome viram uma só; lightbox sem valor usa o nome gallery.
O fslightbox é carregado no app.js, antes dos carrosséis: ele varre o DOM ao entrar e guarda a ordem que encontrou. Só entra na página que tem alguma âncora data-fslightbox.
Mapa incorporado num <iframe>. O endereço sai de goognet-ui.location.map por padrão, então a página não repete a URL do embed.
x-ui.map
Prop
Padrão
src
null
title
'Mapa de localização'
ratio
'video'
eager
false
O iframe leva sandbox sem allow-top-navigation: um clique dentro do mapa não consegue levar a página inteira para outro endereço. Medido com Google e OSM — renderizam igual e o "Abrir no Maps" continua abrindo nova aba.
Só abre hosts de goognet-ui.security.frame_hosts (Google Maps e OpenStreetMap por padrão) e só em https. Um endereço vindo de painel não vira página de outro site dentro do seu.
Sem src ele usa goognet-ui.location.map, que vem de LOCATION_MAP_LINK no .env.
A URL do Google Maps só sai do diálogo Compartilhar → Incorporar um mapa: é uma string opaca que começa com /maps/embed?pb= e não dá para escrever à mão. O formato antigo maps.google.com/?output=embed hoje redireciona para uma página com X-Frame-Options: sameorigin, que o navegador recusa enquadrar — por isso os exemplos aqui usam OpenStreetMap, que enquadra sem chave.
Link vazio não renderiza nada. <iframe src=""> não é quadro vazio: o navegador resolve a string vazia contra o documento atual e carrega a própria página dentro da caixa.
O ratio reserva a altura antes dos tiles chegarem. O embed do Google não tem tamanho intrínseco, então sem proporção a caixa fica com altura zero e empurra a página quando termina de carregar — o layout shift que o Core Web Vitals mede.
Valores de ratio: video (padrão), square, wide, tall, qualquer utility aspect-*, ou :ratio="false" quando o pai já tem altura.
O eager desliga o loading="lazy". Só vale quando o mapa já está na primeira tela — abaixo dela ele antecipa uma requisição de terceiro que talvez ninguém role para ver.
O title é obrigatório em <iframe> para o leitor de tela dizer o que há na moldura, e o W3C cobra. Tem padrão, mas vale trocar pelo endereço real.
referrerpolicy="no-referrer-when-downgrade" é o que a documentação do embed do Google pede; o padrão do navegador é mais restrito e corta o caminho que ele usa para resolver o lugar.
O iframe é de terceiro e só carrega quando entra na viewport, pelo loading="lazy". O x-ui.cookie-consent do projeto é informativo e não barra carregamento nenhum — o mapa não passa por ele.
<x-ui.map
src="https://www.openstreetmap.org/export/embed.html?bbox=-46.67%2C-23.57%2C-46.64%2C-23.55&layer=mapnik"
title="Onde fica a loja da Avenida Paulista"
ratio="wide"
class="rounded-xl"
/>
Trilho de índice que acompanha a leitura. Ele gruda abaixo da navbar e marca a seção em que a pessoa está enquanto ela rola.
x-ui.sidebar
Prop
Padrão
items
[]
label
—
obrigatório
title
'Nesta página'
sticky
true
Cada item também aceita route no lugar de url, igual ao x-ui.menu e pelo mesmo motivo: nome de rota sobrevive a mudança de caminho.
O items aceita duas formas: mapa de âncora para rótulo (['cookies' => 'Cookies']), que é o que uma página com seções já tem na mão, ou lista de ['label' => ..., 'url' => ...], a mesma forma do config('goognet-ui.menu') e do x-ui.menu.
A marcação da seção atual só liga quando todos os itens são âncoras da própria página. Lista de URLs é navegação entre páginas, e ali quem manda é o endereço, não o scroll.
O item atual ganha aria-current="location", não page: ele aponta para um lugar dentro desta página, não para outra página.
O sticky usa --navbar-height, publicado em tempo de execução pelo resources/js/navbar.js — a altura da barra muda conforme o site preencha ou não a faixa de informações. Com um valor fixo, o topo do trilho ficava atrás do cabeçalho.
Fixado, o trilho é limitado à altura da tela e rola por dentro. Trilho fixo mais alto que a janela não tem como alcançar o próprio pé: a página rola, ele não acompanha, e os últimos itens ficam permanentemente abaixo da dobra. Medido no catálogo, a lista passou da tela no 22º componente e o último ficou 76px fora de alcance.
A rolagem fica na lista, não no trilho inteiro: o rótulo de cima fica parado e só os itens andam, que é o que avisa a pessoa de que tem mais coisa ali.
O item marcado é trazido para dentro da vista do trilho quando ele rola por dentro — senão numa lista longa a marcação acontece fora da tela, num trilho que está bem ali.
A seção atual é a última que já chegou ao lugar onde a âncora dela estaciona, não a que está visível. Três seções cabem na tela ao mesmo tempo, e marcar "visível" faz a marcação piscar entre elas.
A linha de comparação sai do scroll-margin-top de cada seção, não de um número escolhido a dedo. Medido: com 104px fixos contra as seções da política, que param em 112px, toda entrada acendia uma atrás do leitor.
Clicar acende a entrada clicada na hora e segura até a página parar de andar. Sem isso, o scroll suave leva ~1,6s e a marcação caminha por todas as seções do caminho — o item clicado só acende no fim, o que se lê como o trilho marcando o item errado. A trava solta no scrollend, com um temporizador de reserva para navegador que não dispara esse evento.
Sem JavaScript o trilho continua funcionando: são âncoras comuns. O que se perde é só a marcação de onde a pessoa está.
Para tirar o rótulo de cima use title="", não :title="null". O Blade compila os padrões de @props como $$__key = $$__key ?? $__value, então passar null de propósito cai de volta no padrão — string vazia é o único valor que limpa um. Vale para qualquer componente da lib.
O label é obrigatório porque uma página costuma ter várias navegações, e o leitor de tela as lista por esse nome — "navegação, navegação, navegação" não diz qual abrir.
Pôster clicável de um vídeo do YouTube, que abre no lightbox. Nada do YouTube carrega até o clique: a página só busca a imagem de capa.
x-ui.video
Prop
Padrão
url
—
obrigatório
title
null
poster
null
quality
'max'
ratio
'video'
lightbox
true
eager
false
O url aceita as seis formas que o YouTube distribui: watch?v=, youtu.be, /embed/, /shorts/, /live/, /v/ e o id puro. O link curto é o que sai do botão de compartilhar, e ler só a query string deixava ele de fora.
Link que não é do YouTube estoura InvalidArgumentException, como no x-ui.modal e no x-ui.tabs. Endereço errado é engano de quem escreveu a página, não estado de tempo de execução.
Nenhuma requisição ao YouTube acontece no render. Goognet\Ui\Support\Youtube é só manipulação de string; a descoberta de qual capa existe é feita pelo navegador, com o fallback em resources/js/video.js.
Capas: max (1280x720, padrão) só existe se o upload foi HD; standard, high e medium sempre existem.
A troca da capa que falta não escuta error. Medido: o 404 do maxresdefault vem com um JPEG cinza de 120x90 no corpo, então o navegador decodifica e dispara load — error nunca acontece. O resources/js/video.js olha o naturalWidth.
A high é 480x360, ou seja 4:3 — vídeo 16:9 nela vem com tarja preta. O object-cover dentro do aspect-video corta as tarjas de volta.
Com poster a capa sai do próprio projeto pelo x-ui.image: webp, srcset e nenhuma requisição a terceiro antes do clique.
O véu escuro sobre a capa não é enfeite. O botão é branco, e a capa é qualquer imagem — um quadro de neve ou um quadro branco apagariam o controle.
Sem pulso infinito. O botão responde ao ponteiro, como o resto da biblioteca; movimento que começa sozinho e não para é o que a WCAG 2.2.2 manda dar como desligar.
O nome acessível do link é texto sr-only, não o alt da capa. A capa é decorativa (alt="") porque o link já diz o que ela é — duas descrições da mesma coisa fazem o leitor de tela repetir.
O fsLightbox é carregado por resources/js/lightbox.js, compartilhado com o x-ui.carousel. Ele varre o DOM uma vez, no import, então o import é único e acontece antes de o Swiper embaralhar os slides.
Seção com vídeo de fundo, véu e conteúdo por cima. O plugin videos() do vite.config.jsdo site corta o master em webm e h264, e o componente aponta para as duas saídas — quem cobra a existência do arquivo é o próprio Vite.
x-ui.video-background
Prop
Padrão
src
—
obrigatório
poster
null
loop
true
overlay
'bg-neutral-950/75'
height
'h-svh'
O vídeo traz muted, playsinline e autoplay juntos porque os três são necessários: sem muted nenhum navegador autoplay; sem playsinline o Safari do iOS recusa tocar embutido e joga para tela cheia.
O wrapper usa isolate. É isso que torna o z-index negativo seguro: abre um contexto de empilhamento, então o vídeo fica atrás do conteúdo desta seção e não atrás do fundo de um ancestral — que é quando ele some da tela.
As duas saídas são pedidas sempre, sem checar o disco antes. A checagem existia e foi tirada de propósito: ela engolia o erro do Vite e deixava a seção preta sem dizer por quê.
O poster também vai como background-image no wrapper, para o intervalo entre o primeiro pixel e o primeiro quadro não ser um vazio.
loop é ligado por padrão: fundo que termina congela num quadro qualquer.
Sob prefers-reduced-motion: reduce o vídeo é escondido e o poster assume, via [data-video-background] no ui.css. O CSS esconde mas não cancela o download — por isso o preload="metadata" na tag.
O vídeo é decorativo: aria-hidden e tabindex="-1". Conteúdo que precisa ser lido vai no slot.
Fluxo do arquivo: você põe fundo.mp4 em resources/videos e o build escreve fundo.webm, fundo.h264.mp4 e fundo.jpg ao lado. O master não vai para o bundle — o assets do Vite lista só as saídas.
O mp4 é reencodado, não copiado, por causa do -movflags +faststart: sem ele o átomo moov fica no fim do arquivo e o navegador só começa a tocar depois de baixar tudo. Medido num master 2560×1440: moov no byte 36, mdat no 1952.
As saídas são mudas (-an) e capadas em 1920px de largura. Vídeo de fundo toca sempre com muted, e mais largura que isso o object-fit: cover corta fora. No mesmo master: 139,3 kB → 57,0 kB em h264 e 40,8 kB em webm.
O poster é gerado do primeiro quadro só se não existir um .jpg ao lado — poster escolhido à mão nunca é sobrescrito.
Nome errado ou arquivo fora do manifest estoura a ViteException padrão, na tela, apontando o que faltou — o mesmo erro que qualquer outro asset do Vite dá. Sem tratamento próprio: um erro de build tem que aparecer.
O encode aparece no terminal conforme sai (videos: fundo.webm 220,3 kB (12764 ms)). Sem isso, um clipe de 20s deixa o Vite mudo por 17s e parece travado.
Em npm run dev o servidor sobe sem esperar o encode; em npm run build ele espera, porque o manifest é escrito a partir do que está no disco.
Hero com chamada
Sem prévia: exige um master em resources/videos, que não é versionado. Com o arquivo no lugar, este código roda como está.
blade
<x-ui.video-background src="fundo" poster="https://picsum.photos/id/1015/1200/675" class="flex items-center">
<x-ui.container>
<div class="max-w-2xl space-y-6 text-white">
<h1 class="text-5xl sm:text-6xl">Corte a laser e dobra sob medida</h1>
<p class="max-w-lg">Precisão, acabamento técnico e atendimento personalizado.</p>
<x-ui.button variant="primary" href="/contato">Solicite um orçamento</x-ui.button>
</div>
</x-ui.container>
</x-ui.video-background>
Sem véu, altura própria
Sem prévia, pelo mesmo motivo.
blade
<x-ui.video-background src="fundo" :overlay="false" height="h-96" class="flex items-end">
<x-ui.container>
<p class="pb-8 text-white">Sem véu, o texto precisa do seu próprio contraste.</p>
</x-ui.container>
</x-ui.video-background>
<x-ui.whatsapp>Falar no WhatsApp</x-ui.whatsapp>
<x-ui.whatsapp phone="5511999999999" message="Vim pela página de preços">Outro número</x-ui.whatsapp>
<x-ui.whatsapp title="Atendimento comercial">Com título próprio</x-ui.whatsapp>