Abas (controle segmentado)
Barra de opções em que só uma fica ativa, para trocar visão, período ou filtro. Aceita contador e ícone em cada opção, dois tamanhos, largura total e quebra de linha; setas e Home/End navegam entre as opções.
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<Abas>
Controle segmentado: uma barra de opções em que só uma fica ativa, para trocar a visão, o período ou o filtro de uma lista. Controlado (quem usa guarda o valor). Setas ←/→ andam entre as opções, Home/End vão às pontas, e só a opção ativa fica na ordem do Tab.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| valorobrigatória | T | — | A opção ativa. |
| aoMudarobrigatória | (valor: T) => void | — | |
| opcoesobrigatória | readonly OpcaoDeAba<T>[] | — | |
| rotuloobrigatória | string | — | Nome do grupo para leitores de tela ("Período do relatório"). |
| tamanho | "sm" | "md" | "md" | `md` acompanha botões e campos de tamanho médio; `sm` é para barras densas. |
| bloco | boolean | false | Ocupa a largura do contêiner, com as opções dividindo o espaço em partes iguais. |
| quebrar | boolean | false | Deixa as opções quebrarem para a linha de baixo em telas estreitas (rótulos longos ou muitas opções). |
| desabilitado | boolean | false | Desliga a barra inteira (por exemplo, enquanto a troca anterior carrega). |
| className | string | "" |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import { useRef, type KeyboardEvent, type ReactNode } from "react";
export type OpcaoDeAba<T extends string> = {
valor: T;
/** O texto da opção. Comece com maiúscula. */
rotulo: string;
/** Número ao lado do rótulo ("Pendentes 12"). */
contador?: number;
/** Ícone antes do rótulo (SVG; sem classe de tamanho, fica com size-4). */
icone?: ReactNode;
/** Texto ao passar o mouse. */
dica?: string;
};
const TAMANHOS = {
sm: { casca: "gap-0.5 p-0.5", raio: "rounded-[max(var(--radius-sm),calc(var(--radius-lg)_-_var(--spacing)*0.5))]", opcao: "h-7 px-2.5 text-xs" },
md: { casca: "gap-1 p-1", raio: "rounded-[max(var(--radius-sm),calc(var(--radius-lg)_-_var(--spacing)*1))]", opcao: "h-8 px-3 text-sm" },
} as const;
/**
* Controle segmentado: uma barra de opções em que só uma fica ativa, para trocar
* a visão, o período ou o filtro de uma lista. Controlado (quem usa guarda o
* valor). Setas ←/→ andam entre as opções, Home/End vão às pontas, e só a
* opção ativa fica na ordem do Tab.
*/
export function Abas<T extends string>({
valor,
aoMudar,
opcoes,
rotulo,
tamanho = "md",
bloco = false,
quebrar = false,
desabilitado = false,
className = "",
}: {
/** A opção ativa. */
valor: T;
aoMudar: (valor: T) => void;
opcoes: ReadonlyArray<OpcaoDeAba<T>>;
/** Nome do grupo para leitores de tela ("Período do relatório"). */
rotulo: string;
/** `md` acompanha botões e campos de tamanho médio; `sm` é para barras densas. */
tamanho?: keyof typeof TAMANHOS;
/** Ocupa a largura do contêiner, com as opções dividindo o espaço em partes iguais. */
bloco?: boolean;
/** Deixa as opções quebrarem para a linha de baixo em telas estreitas (rótulos longos ou muitas opções). */
quebrar?: boolean;
/** Desliga a barra inteira (por exemplo, enquanto a troca anterior carrega). */
desabilitado?: boolean;
className?: string;
}) {
const botoes = useRef<(HTMLButtonElement | null)[]>([]);
const t = TAMANHOS[tamanho];
// Roving tabindex: a ativa (ou a primeira, se o valor não está nas opções) recebe o Tab.
const indiceDoTab = Math.max(0, opcoes.findIndex((o) => o.valor === valor));
function aoTeclar(e: KeyboardEvent<HTMLDivElement>) {
const atual = botoes.current.findIndex((b) => b === e.target);
if (atual < 0) return;
const n = opcoes.length;
// As setas dão a volta nas pontas.
const destinos: Record<string, number> = {
ArrowRight: (atual + 1) % n,
ArrowLeft: (atual - 1 + n) % n,
Home: 0,
End: n - 1,
};
const destino = destinos[e.key];
if (destino === undefined) return;
e.preventDefault();
botoes.current[destino]?.focus();
}
return (
// Não é role="tablist": tablist promete um tabpanel ligado a cada aba (aria-controls),
// e aqui as opções trocam um conteúdo que o componente não conhece (a página, um
// gráfico, o filtro de uma lista). Um grupo de botões com aria-pressed descreve o
// que de fato acontece. As setas seguem o padrão de barra de ferramentas: movem o
// foco, e Enter/Espaço escolhem.
<div
role="group"
aria-label={rotulo}
onKeyDown={aoTeclar}
// Sem `quebrar`, opções que não cabem rolam na horizontal em vez de vazar da tela
className={`${bloco ? "flex w-full" : "inline-flex max-w-full"} ${quebrar ? "flex-wrap" : "overflow-x-auto [scrollbar-width:none]"} items-center rounded-lg border border-rule bg-surface ${t.casca} ${className}`}
>
{opcoes.map((o, i) => {
const ativa = o.valor === valor;
return (
<button
key={o.valor}
ref={(el) => {
botoes.current[i] = el;
}}
type="button"
aria-pressed={ativa}
tabIndex={i === indiceDoTab ? 0 : -1}
title={o.dica}
disabled={desabilitado}
onClick={() => aoMudar(o.valor)}
className={`inline-flex cursor-pointer items-center gap-1.5 ${t.raio} font-medium whitespace-nowrap transition-colors outline-accent focus-visible:outline-2 focus-visible:outline-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 ${t.opcao} ${
bloco ? "flex-1 justify-center" : ""
} ${ativa ? "bg-page text-accent ring-1 ring-rule" : "text-ink-2 hover:text-ink"}`}
>
{o.icone}
{o.rotulo}
{typeof o.contador === "number" && (
<span className={`tabular-nums ${ativa ? "text-accent" : "text-muted"}`}>{o.contador}</span>
)}
</button>
);
})}
</div>
);
}
import { useState, type ReactNode } from "react";
import { defineStories } from "@/registry/stories";
import { Abas, type OpcaoDeAba } from "./Abas";
function Icone({ children }: { children: ReactNode }) {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
{children}
</svg>
);
}
const PERIODOS = [
{ valor: "7d", rotulo: "7 dias" },
{ valor: "30d", rotulo: "30 dias" },
{ valor: "90d", rotulo: "90 dias" },
{ valor: "12m", rotulo: "12 meses" },
] as const;
const SITUACOES = [
{ valor: "todas", rotulo: "Todas", contador: 48 },
{ valor: "pendentes", rotulo: "Pendentes", contador: 12 },
{ valor: "concluidas", rotulo: "Concluídas", contador: 36 },
] as const;
const VISOES = [
{
valor: "geral",
rotulo: "Visão geral",
icone: (
<Icone>
<path d="M4 20V10M10 20V4M16 20v-7M22 20H2" />
</Icone>
),
},
{
valor: "equipe",
rotulo: "Por equipe",
icone: (
<Icone>
<circle cx="9" cy="8" r="3.5" />
<path d="M2.5 20a6.5 6.5 0 0 1 13 0M16 4.5a3.5 3.5 0 0 1 0 7M18 14a6.5 6.5 0 0 1 3.5 6" />
</Icone>
),
},
{
valor: "produto",
rotulo: "Por produto",
icone: (
<Icone>
<path d="m12 3 8.5 4.5v9L12 21l-8.5-4.5v-9L12 3zM3.5 7.5 12 12l8.5-4.5M12 12v9" />
</Icone>
),
},
] as const;
const SECOES = [
{ valor: "perfil", rotulo: "Perfil e preferências" },
{ valor: "equipes", rotulo: "Equipes e permissões" },
{ valor: "integracoes", rotulo: "Integrações" },
{ valor: "cobranca", rotulo: "Cobrança e notas fiscais" },
{ valor: "seguranca", rotulo: "Segurança" },
] as const;
function Exemplo<T extends string>({
opcoes,
rotulo,
...resto
}: {
opcoes: ReadonlyArray<OpcaoDeAba<T>>;
rotulo: string;
tamanho?: "sm" | "md";
bloco?: boolean;
quebrar?: boolean;
desabilitado?: boolean;
}) {
const [valor, setValor] = useState<T>(opcoes[0].valor);
return <Abas valor={valor} aoMudar={setValor} opcoes={opcoes} rotulo={rotulo} {...resto} />;
}
export default defineStories({
args: { tamanho: "md", comContador: true, bloco: false, quebrar: false, desabilitado: false },
controls: {
tamanho: { type: "select", options: ["sm", "md"] },
comContador: { type: "boolean", label: "contador nas opções" },
bloco: { type: "boolean" },
quebrar: { type: "boolean" },
desabilitado: { type: "boolean" },
},
render: (a) => (
<div className="w-full max-w-md">
<Exemplo
rotulo="Situação das tarefas"
opcoes={a.comContador ? SITUACOES : SITUACOES.map(({ valor, rotulo }) => ({ valor, rotulo }))}
tamanho={a.tamanho as "sm" | "md"}
bloco={a.bloco}
quebrar={a.quebrar}
desabilitado={a.desabilitado}
/>
</div>
),
stories: [
{ name: "Padrão" },
{
name: "Período e situação",
render: () => (
<div className="flex flex-col items-start gap-4">
<Exemplo rotulo="Período do relatório" opcoes={PERIODOS} />
<Exemplo rotulo="Situação das tarefas" opcoes={SITUACOES} />
</div>
),
},
{ name: "Com ícones", render: () => <Exemplo rotulo="Visão do painel" opcoes={VISOES} /> },
{ name: "Pequena", render: () => <Exemplo rotulo="Período do relatório" opcoes={PERIODOS} tamanho="sm" /> },
{
name: "Bloco (campo de formulário)",
render: () => (
<div className="flex w-full max-w-md flex-col gap-2">
<span className="text-sm text-ink-2">Destinatários</span>
<Exemplo
rotulo="Destinatários"
bloco
opcoes={[
{ valor: "todos", rotulo: "Todos" },
{ valor: "equipe", rotulo: "Por equipe" },
{ valor: "escolher", rotulo: "Escolher" },
]}
/>
</div>
),
},
{
name: "Quebrando linha",
render: () => (
<div className="w-full max-w-sm">
<Exemplo rotulo="Seções das configurações" opcoes={SECOES} quebrar />
</div>
),
},
],
});
Para usar em outro projeto, copie também
Os componentes de que este depende e o tema. O CSS do tema é um arquivo só: importe depois do Tailwind.
Tema escolhido no cabeçalho (com a ponte para o Tailwind)
Ver o CSS do tema
/* Tema "Editorial", gerado no Component Vault.
Importe depois de @import "tailwindcss". Carregue as fontes "Archivo" e "Inter" no projeto. */
@theme inline {
--color-page: var(--page);
--color-surface: var(--surface);
--color-track: var(--track);
--color-grid: var(--grid);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-muted: var(--muted);
--color-rule: var(--rule);
--color-rule-strong: var(--rule-strong);
--color-axis: var(--axis);
--color-accent: var(--accent);
--color-success: var(--success);
--color-warning: var(--warning);
--color-critical: var(--critical);
--font-display: var(--fonte-titulo);
--shadow-flutuante: var(--sombra-flutuante);
}
:root {
color-scheme: light dark;
--page: light-dark(#ffffff, #0f0f0e);
--surface: light-dark(#f5f5f5, #1a1a19);
--track: light-dark(#f0f0f0, #262624);
--grid: light-dark(#ededed, #232321);
--ink: light-dark(#111111, #f0efec);
--ink-2: light-dark(#4a4a4a, #c3c2b7);
--muted: light-dark(#666666, #a3a29a);
--rule: light-dark(#e3e3e3, #2c2c2a);
--rule-strong: light-dark(#111111, #f0efec);
--axis: light-dark(#9a9a9a, #6b6a64);
--accent: light-dark(#1a56db, #7aa2f7);
--success: light-dark(#13865c, #3fc48f);
--warning: #fab219;
--critical: #d03b3b;
--series-1: light-dark(#2a78d6, #3987e5);
--series-2: light-dark(#eb6834, #d95926);
--series-3: light-dark(#1baf7a, #199e70);
--series-4: light-dark(#eda100, #c98500);
--series-5: light-dark(#e87ba4, #d55181);
--series-6: #008300;
--series-7: light-dark(#6250d6, #9085e9);
--series-8: light-dark(#e34948, #e66767);
--fonte-titulo: "Archivo", system-ui, -apple-system, "Segoe UI", sans-serif;
--fonte-texto: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
--spacing: 0.25rem;
--text-xs: 0.8rem;
--text-sm: 0.8944rem;
--text-base: 1rem;
--text-lg: 1.118rem;
--text-xl: 1.25rem;
--text-2xl: 1.5625rem;
--text-3xl: 1.9531rem;
--text-4xl: 2.4414rem;
--text-5xl: 3.0518rem;
--text-6xl: 3.8147rem;
--text-7xl: 4.7684rem;
--radius-xs: 2px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
--radius-3xl: 24px;
--radius-4xl: 32px;
--sombra-flutuante: 0 8px 24px -6px rgb(0 0 0 / 0.14), 0 2px 6px -2px rgb(0 0 0 / 0.08);
color: var(--ink);
font-family: var(--fonte-texto);
}
Na vitrine, a ponte com o Tailwind fica em src/themes/tokens.css:
/* Ponte entre os tokens do tema e o Tailwind.
Os componentes da biblioteca usam classes comuns do Tailwind (p-5, text-sm,
rounded-xl...) e as cores semânticas abaixo (bg-page, text-ink...). Dentro de
[data-theme="vault"], o tema redefine as variáveis que essas classes leem:
--spacing, --text-*, --radius-* e as cores. Os valores vêm do tema escolhido
na vitrine (src/themes/presets.ts) e são gerados por themeCss(). */
@theme inline {
--color-page: var(--page);
--color-surface: var(--surface);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-muted: var(--muted);
--color-rule: var(--rule);
--color-rule-strong: var(--rule-strong);
--color-track: var(--track);
--color-grid: var(--grid);
--color-axis: var(--axis);
--color-accent: var(--accent);
--color-success: var(--success);
--color-warning: var(--warning);
--color-critical: var(--critical);
--font-display: var(--fonte-titulo);
--shadow-flutuante: var(--sombra-flutuante);
}