Biblioteca de componentes v0.9.0 GitHub

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.

instalação

composer require goognet/ui
php artisan goognet-ui:install
npm install swiper fslightbox

Personalização

Cada site muda a aparência sem copiar componente nenhum, em três camadas. Nada disso se perde ao atualizar o pacote.

1. Tokens — a identidade inteira em poucas linhas

No @theme do site, depois do import do pacote. Todo controle acompanha.

@theme {
    --color-primary-500: var(--color-blue-500);
    --radius-control: 0;
    --font-weight-control: 700;
    --spacing-control: 3rem;
}

2. Classe na chamada — sempre vence

Uma classe passada substitui a do componente para a mesma propriedade, em vez de brigar com ela.

<x-ui.button class="rounded-full uppercase">Enviar</x-ui.button>

3. Por projeto, em PHP — variantes, tamanhos e partes

No AppServiceProvider do site. A chamada ainda tem a última palavra sobre isto.

use Goognet\Ui\Ui;

Ui::button()
    ->defaults(['variant' => 'primary', 'rounded' => 'full'])
    ->variant('outline', 'border-2 border-primary bg-transparent text-primary-ink')
    ->size('xl', 'h-14 px-8 text-lg')
    ->part('base', 'uppercase tracking-wide');

Ú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.

Variantes

blade
<x-ui.button>Padrão</x-ui.button>
<x-ui.button variant="primary">Primary</x-ui.button>
<x-ui.button variant="secondary">Secondary</x-ui.button>
<x-ui.button variant="filled">Filled</x-ui.button>
<x-ui.button variant="ghost">Ghost</x-ui.button>

Tamanhos e raio

blade
<x-ui.button size="xs">xs</x-ui.button>
<x-ui.button size="sm">sm</x-ui.button>
<x-ui.button size="base">base</x-ui.button>
<x-ui.button size="lg">lg</x-ui.button>
<x-ui.button rounded>rounded</x-ui.button>
<x-ui.button rounded="lg">rounded="lg"</x-ui.button>

Ícone, quadrado, link e estados

Vira uma âncora
blade
<x-ui.button icon="heroicon-m-paper-airplane">Enviar</x-ui.button>
<x-ui.button icon-trailing="heroicon-m-arrow-right">Continuar</x-ui.button>
<x-ui.button square icon="heroicon-o-trash" variant="ghost">
    <span class="sr-only">Excluir</span>
</x-ui.button>
<x-ui.button href="/orcamento" variant="primary">Vira uma âncora</x-ui.button>
<x-ui.button loading>Carregando</x-ui.button>
<x-ui.button disabled>Desabilitado</x-ui.button>

Link externo e tipo de submit

Abre em nova aba
blade
<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.

Variantes

Padrão Primary Secondary Filled Ghost
blade
<x-ui.badge>Padrão</x-ui.badge>
<x-ui.badge variant="primary">Primary</x-ui.badge>
<x-ui.badge variant="secondary">Secondary</x-ui.badge>
<x-ui.badge variant="filled">Filled</x-ui.badge>
<x-ui.badge variant="ghost">Ghost</x-ui.badge>

Cor semântica por classe

Pago Pendente Atrasado Rascunho
blade
<x-ui.badge class="bg-green-100 text-green-800" dot>Pago</x-ui.badge>
<x-ui.badge class="bg-amber-100 text-amber-800" dot>Pendente</x-ui.badge>
<x-ui.badge class="bg-red-100 text-red-800" dot>Atrasado</x-ui.badge>
<x-ui.badge class="border-2 border-neutral-300 bg-transparent text-neutral-700">Rascunho</x-ui.badge>

Tamanhos, raio, ícone e link

xs sm base lg rounded="md" Concluído Novo
blade
<x-ui.badge size="xs">xs</x-ui.badge>
<x-ui.badge size="sm">sm</x-ui.badge>
<x-ui.badge size="base">base</x-ui.badge>
<x-ui.badge size="lg">lg</x-ui.badge>
<x-ui.badge rounded="md" variant="filled">rounded="md"</x-ui.badge>
<x-ui.badge variant="filled" icon="heroicon-m-check">Concluído</x-ui.badge>
<x-ui.badge href="/tags/novo" variant="primary" icon-trailing="heroicon-m-arrow-right">Novo</x-ui.badge>

Etiqueta que abre em nova aba

blade
<x-ui.badge href="https://goognet.com.br" external variant="filled">Parceiro</x-ui.badge>

Table

<x-ui.table>

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'],
    ]"
/>

Linhas vindas do banco, lidas por chave

Cidade Prazo
São Paulo 2 dias
Campinas 3 dias
blade
<x-ui.table
    size="sm"
    :headers="[
        ['key' => 'cidade', 'label' => 'Cidade'],
        ['key' => 'prazo', 'label' => 'Prazo', 'align' => 'end'],
    ]"
    :rows="[
        ['cidade' => 'São Paulo', 'prazo' => '2 dias'],
        ['cidade' => 'Campinas', 'prazo' => '3 dias'],
    ]"
/>

Escrita à mão

Serviço Situação
Consultoria Ativo
blade
<x-ui.table :headers="['Serviço', 'Situação']">
    <tr>
        <td class="px-4 py-3 text-sm">Consultoria</td>
        <td class="px-4 py-3 text-sm"><x-ui.badge>Ativo</x-ui.badge></td>
    </tr>
</x-ui.table>

Tooltip

<x-ui.tooltip>

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

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 dias

left right
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.

Indicadores

1.250+

projetos entregues

98,5%

satisfação

12

anos de casa

blade
<div class="text-center">
    <x-ui.counter :value="1250" suffix="+" />
    <x-ui.text size="sm" class="mt-1 text-neutral-500">projetos entregues</x-ui.text>
</div>

<div class="text-center">
    <x-ui.counter :value="98.5" :decimals="1" suffix="%" size="lg" />
    <x-ui.text size="sm" class="mt-1 text-neutral-500">satisfação</x-ui.text>
</div>

<div class="text-center">
    <x-ui.counter :value="12" :start="0" :duration="3" size="sm" />
    <x-ui.text size="sm" class="mt-1 text-neutral-500">anos de casa</x-ui.text>
</div>

Moeda e separadores

R$ 1.234.567,89
blade
<x-ui.counter :value="1234567.89" :decimals="2" prefix="R$ " separator="." decimal="," size="xl" />

Card

<x-ui.card>

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>

Cabeçalho, rodapé e mídia

blade
<x-ui.card href="/servicos/consultoria" padding="base" class="max-w-sm">
    <x-slot:media>
        <x-ui.image src="https://picsum.photos/seed/card/800/450" alt="" class="aspect-video w-full object-cover" />
    </x-slot:media>

    <x-slot:header>
        <x-ui.heading :level="3" size="sm">Consultoria tributária</x-ui.heading>
    </x-slot:header>

    <x-ui.text size="sm">Revisão de regime e recuperação de créditos.</x-ui.text>

    <x-slot:footer>
        <x-ui.text size="sm" class="text-neutral-500">Saiba mais</x-ui.text>
    </x-slot:footer>
</x-ui.card>

Espaçamento

none
sm
lg
external
blade
<x-ui.card padding="none" class="max-w-[10rem]">none</x-ui.card>
<x-ui.card padding="sm" class="max-w-[10rem]">sm</x-ui.card>
<x-ui.card padding="lg" class="max-w-[10rem]">lg</x-ui.card>
<x-ui.card href="https://goognet.com.br" external class="max-w-[10rem]">external</x-ui.card>

Toast

<x-ui.toast>

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.'); --}}

Direto, sem passar pela sessão

blade
<x-ui.toast message="Orçamento salvo." title="Pronto" type="success" position="top-end" :duration="4000" />
<x-ui.toast message="Não foi possível enviar agora." type="error" session="meu-aviso" />

Confirmar antes de agir

blade
<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

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.

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.

Um grupo

Inclui suporte prioritário.

blade
<x-ui.radio name="plano" value="lite" label="Lite" checked />
<x-ui.radio name="plano" value="pro" label="Pro" hint="Inclui suporte prioritário." />
<x-ui.radio name="plano" id="plano-custom" value="sob-medida" label="Sob medida" error="Escolha um plano." required />

Input

<x-ui.input>

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" />

Erro

blade
<x-ui.input name="cnpj" id="cnpj-do-cliente" label="CNPJ" error="Informe um CNPJ válido." value="00.000.000/0000-00" control-class="font-mono" />

Máscara

blade
<x-ui.input name="telefone" label="Telefone" mask="phone" placeholder="(11) 90000-0000" />
<x-ui.input name="documento" label="CPF ou CNPJ" mask="cpf-cnpj" />
<x-ui.input name="valor" label="Valor" mask="money" placeholder="0,00" />
<x-ui.input name="placa" label="Placa" mask="AAA-0A00" />

Tamanhos

blade
<x-ui.input name="a" size="sm" placeholder="sm" />
<x-ui.input name="b" size="base" placeholder="base" />
<x-ui.input name="c" size="lg" placeholder="lg" />

Textarea

<x-ui.textarea>

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.

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.

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.

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>

Image

<x-ui.image>

Imagem responsiva. O plugin images() do vite.config.js do 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/images quebra 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.

Imagem de conteúdo

Exemplo
blade
<x-ui.image src="https://picsum.photos/id/1015/1200/675" alt="Exemplo" class="w-full rounded-lg" />

Vetor

Logo
blade
<x-ui.image src="https://cdn.simpleicons.org/laravel/FF2D20" alt="Logo" class="h-10" />

Imagem principal da página

Sem prévia: este exemplo depende de um arquivo que não vive no repositório.

blade
<x-ui.image
    src="hero.jpg"
    alt="Exemplo"
    sizes="(min-width: 768px) 50vw, 100vw"
    class="w-full rounded-lg"
    eager
/>

Restringindo as larguras

Sem prévia: este exemplo depende de um arquivo que não vive no repositório.

blade
<x-ui.image src="hero.jpg" alt="Exemplo" :widths="[400, 800]" class="w-full rounded-lg" />

Rating

<x-ui.rating>

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.

Exibir uma nota

blade
<x-ui.rating :value="4.5" />
<x-ui.rating :value="4.3" />
<x-ui.rating :value="3" shape="heart" />
<x-ui.rating :value="7" :max="10" size="sm" />

Campo de formulário

Como foi o atendimento?
Nota de 1 a 5
Nota de 1 a 5
blade
<x-ui.rating name="atendimento" label="Como foi o atendimento?" :value="4" />
<x-ui.rating name="entrega" clearable />
<x-ui.rating name="travado" :value="3" disabled />

Tamanhos

blade
<x-ui.rating :value="4" size="xs" />
<x-ui.rating :value="4" size="sm" />
<x-ui.rating :value="4" size="base" />
<x-ui.rating :value="4" size="lg" />
<x-ui.rating :value="4" size="xl" />

Heading

<x-ui.heading>

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>

Brand

<x-ui.brand>

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.

Padrão e com link

blade
<x-ui.brand class="h-8 w-auto" />
<x-ui.brand :href="url('/')" class="h-8 w-auto" />

Arquivo ou URL, alt e link externo

blade
<x-ui.brand logo="https://cdn.simpleicons.org/laravel/FF2D20" alt="Marca do cliente" class="h-8 w-auto" />
<x-ui.brand logo="https://cdn.simpleicons.org/vuedotjs" alt="Outra marca" class="h-8 w-auto" />
<x-ui.brand href="https://goognet.com.br" external alt="Site da agência" class="h-8 w-auto" />

Símbolo com o nome ao lado

Acme Inc.
blade
<x-ui.brand logo="https://cdn.simpleicons.org/laravel/FF2D20" name="Acme Inc." class="size-8" />

Marca própria pelo slot, sem arquivo

blade
<x-ui.brand href="/" name="Launchpad">
    <x-slot:logo class="bg-primary size-8 rounded-lg text-sm font-bold text-neutral-950">
        GN
    </x-slot:logo>
</x-ui.brand>

Container

<x-ui.container>

Faixa central de conteúdo, com a mesma largura máxima e o mesmo respiro lateral do resto do site.

Uso

Conteúdo alinhado ao grid do site
blade
<x-ui.container class="bg-neutral-100 py-4">Conteúdo alinhado ao grid do site</x-ui.container>

Megamenu

<x-ui.megamenu> <x-ui.megamenu-panel>

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.

O painel, aberto

blade
<x-ui.megamenu-panel
    :columns="2"
    :groups="[
        ['label' => 'Contábil', 'children' => [
            ['label' => 'Consultoria', 'url' => '/consultoria', 'icon' => 'heroicon-m-briefcase', 'description' => 'Planejamento tributário'],
            ['label' => 'Auditoria', 'url' => '/auditoria', 'icon' => 'heroicon-m-document-check', 'description' => 'Revisão de demonstrativos'],
        ]],
        ['label' => 'Fiscal', 'children' => [
            ['label' => 'Apuração', 'url' => '/apuracao', 'icon' => 'heroicon-m-calculator', 'description' => 'Mensal e trimestral'],
            ['label' => 'Obrigações', 'url' => '/obrigacoes', 'icon' => 'heroicon-m-clipboard-document-list'],
        ]],
    ]"
/>

Uma coluna só

blade
<x-ui.megamenu-panel
    :columns="1"
    :groups="[
        ['label' => 'Serviços', 'children' => [
            ['label' => 'Consultoria', 'url' => '/consultoria', 'description' => 'Planejamento tributário'],
            ['label' => 'Auditoria', 'url' => '/auditoria'],
        ]],
    ]"
/>

Com gatilho, como no header

blade
<div class="relative">
    <x-ui.megamenu
        label="Serviços"
        :columns="2"
        :groups="[
            ['label' => 'Contábil', 'children' => [
                ['label' => 'Consultoria', 'url' => '/consultoria', 'icon' => 'heroicon-m-briefcase'],
            ]],
            ['label' => 'Fiscal', 'children' => [
                ['label' => 'Apuração', 'url' => '/apuracao', 'icon' => 'heroicon-m-calculator'],
            ]],
        ]"
    >
        <x-ui.button variant="primary" href="/orcamento" size="sm">Peça um orçamento</x-ui.button>
    </x-ui.megamenu>
</div>

Tabs

<x-ui.tabs> <x-ui.tab>

Abas em CSS puro, sem JavaScript: radios escondidos guardam o estado e o painel aparece pelo seletor de irmão adjacente.

x-ui.tabs

Prop Padrão
name null
label 'Abas'

x-ui.tab

Prop Padrão
name herdado do pai obrigatório
label obrigatório
icon null
checked false
  • name é obrigatório: é ele que agrupa os radios. Sem ele o componente lança exceção em vez de renderizar abas que não conversam.
  • Sem nenhuma aba checked, a primeira lidera — resolvido em CSS, com :not(:has(:checked)).
  • As setas do teclado navegam entre as abas de graça, por serem radios. É por isso que não são botões.
  • Atributos extras vão para o painel: <x-ui.tab class="pt-10">.

Três abas, a segunda aberta

blade
<x-ui.tabs name="docs-produto">
    <x-ui.tab label="Descrição">
        <p>Conteúdo rico, HTML à vontade.</p>
    </x-ui.tab>

    <x-ui.tab label="Ficha técnica" icon="heroicon-m-list-bullet" checked>
        <ul class="list-disc space-y-1 ps-5">
            <li>Potência: 1.500 W</li>
            <li>Tensão: 220 V</li>
            <li>Peso: 12 kg</li>
        </ul>
    </x-ui.tab>

    <x-ui.tab label="Downloads">
        <ul class="space-y-1">
            <li><x-ui.link href="#">Manual de instalação (PDF)</x-ui.link></li>
            <li><x-ui.link href="#">Ficha de segurança (PDF)</x-ui.link></li>
        </ul>
    </x-ui.tab>
</x-ui.tabs>

Accordion

<x-ui.accordion> <x-ui.accordion-item>

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>

Map

<x-ui.map>

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.

Mapa da configuração

blade
<x-ui.map src="https://www.openstreetmap.org/export/embed.html?bbox=-46.67%2C-23.57%2C-46.64%2C-23.55&amp;layer=mapnik" />

Proporção e título próprios

blade
<x-ui.map
    src="https://www.openstreetmap.org/export/embed.html?bbox=-46.67%2C-23.57%2C-46.64%2C-23.55&amp;layer=mapnik"
    title="Onde fica a loja da Avenida Paulista"
    ratio="wide"
    class="rounded-xl"
/>

Mapa acima da dobra

blade
<x-ui.map
    src="https://www.openstreetmap.org/export/embed.html?bbox=-46.67%2C-23.57%2C-46.64%2C-23.55&amp;layer=mapnik"
    eager
    ratio="square"
/>

Altura vinda do pai

blade
<div class="h-64">
    <x-ui.map
        src="https://www.openstreetmap.org/export/embed.html?bbox=-46.67%2C-23.57%2C-46.64%2C-23.55&amp;layer=mapnik"
        :ratio="false"
        class="size-full"
    />
</div>

Video

<x-ui.video>

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 loaderror 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.

Shorts em pé, capa mais leve

blade
<x-ui.video
    url="https://www.youtube.com/shorts/dQw4w9WgXcQ"
    ratio="tall"
    quality="high"
    class="max-w-52"
/>

Sem lightbox, abrindo no YouTube

blade
<x-ui.video
    url="dQw4w9WgXcQ"
    :lightbox="false"
    ratio="square"
    eager
    class="max-w-xs"
/>

Capa própria, servida pelo projeto

blade
<x-ui.video url="https://youtu.be/dQw4w9WgXcQ" poster="https://picsum.photos/id/1015/1200/675" class="max-w-xl" />

Video background

<x-ui.video-background>

Seção com vídeo de fundo, véu e conteúdo por cima. O plugin videos() do vite.config.js do 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>

Sem repetir ao terminar

Sem prévia, pelo mesmo motivo.

blade
<x-ui.video-background src="fundo" :loop="false" poster="https://picsum.photos/id/1015/1200/675" height="h-96" />

Whatsapp

<x-ui.whatsapp>

Link para a conversa no WhatsApp. Número e mensagem vêm da configuração global quando não são passados.

x-ui.whatsapp

Prop Padrão
phone null
message null
title 'Vamos conversar?'
  • Os valores padrão são goognet-ui.whatsapp.number e goognet-ui.whatsapp.message, alimentados pelo .env.

Config global e sobrescrita

blade
<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>