Barra de Busca
Campo de busca com lupa, botão de limpar, Esc para apagar e atalho Ctrl+K (⌘K no Mac) para focar de qualquer lugar da página. É a busca do topo da vitrine.
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<BarraDeBusca>
Busca com a lupa à esquerda e um botão para limpar quando há texto. O Esc também limpa (e só fecha um modal em volta quando o campo já está vazio). Funciona sozinha ou dentro de um Campo, que lhe passa rótulo e erro.
Também aceita os 303 atributos nativos do elemento HTML (onClick, aria-*, disabled...), repassados ao elemento.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| tamanho | "sm" | "md" | "lg" | "md" | |
| erro | boolean | — | Borda de erro. Dentro de um Campo, vem da prop `erro` dele. |
| value | string | — | Texto buscado. Sem `value`, o campo guarda o próprio estado (começando em `valorInicial`). |
| valorInicial | string | "" | |
| onChange | (valor: string) => void | — | |
| aoLimpar | () => void | — | Chamado depois do botão de limpar (ou do Esc com texto), quando o campo já está vazio. |
| rotuloLimpar | string | "Limpar busca" | Nome do botão × para leitores de tela. |
| atalho | boolean | false | Ctrl+K (⌘K no Mac) em qualquer lugar da página leva o foco para a busca, e a dica do atalho aparece à direita enquanto o campo está vazio. Use em uma barra só por página. |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import { useEffect, useRef, useState, useSyncExternalStore, type InputHTMLAttributes } from "react";
import { BOTAO_INTERNO, classesDoControle, TAMANHOS, type Visual } from "@/library/campo-de-formulario/Campo";
const nadaMuda = () => () => {};
type PropsDaBusca = Omit<InputHTMLAttributes<HTMLInputElement>, "onChange" | "type" | "size" | "value" | "defaultValue"> &
Omit<Visual, "comIcone"> & {
/** Texto buscado. Sem `value`, o campo guarda o próprio estado (começando em `valorInicial`). */
value?: string;
valorInicial?: string;
onChange?: (valor: string) => void;
/** Chamado depois do botão de limpar (ou do Esc com texto), quando o campo já está vazio. */
aoLimpar?: () => void;
/** Nome do botão × para leitores de tela. */
rotuloLimpar?: string;
/**
* Ctrl+K (⌘K no Mac) em qualquer lugar da página leva o foco para a busca, e
* a dica do atalho aparece à direita enquanto o campo está vazio. Use em uma
* barra só por página.
*/
atalho?: boolean;
};
/**
* Busca com a lupa à esquerda e um botão para limpar quando há texto. O Esc
* também limpa (e só fecha um modal em volta quando o campo já está vazio).
* Funciona sozinha ou dentro de um Campo, que lhe passa rótulo e erro.
*/
export function BarraDeBusca({
value,
valorInicial = "",
onChange,
aoLimpar,
rotuloLimpar = "Limpar busca",
atalho = false,
erro,
tamanho = "md",
placeholder = "Buscar...",
className = "",
...props
}: PropsDaBusca) {
const entrada = useRef<HTMLInputElement>(null);
const [interno, setInterno] = useState(valorInicial);
// No servidor não dá para saber o sistema: mostra "Ctrl K" e troca para ⌘K no Mac depois de hidratar
const mac = useSyncExternalStore(
nadaMuda,
() => /Mac|iPhone|iPad/.test(navigator.platform),
() => false,
);
const atual = value ?? interno;
const t = TAMANHOS[tamanho];
const comDica = atalho && !atual;
useEffect(() => {
if (!atalho) return;
function aoTeclar(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key.toLowerCase() === "k") {
e.preventDefault();
entrada.current?.focus();
entrada.current?.select();
}
}
window.addEventListener("keydown", aoTeclar);
return () => window.removeEventListener("keydown", aoTeclar);
}, [atalho]);
function mudar(valor: string) {
if (value === undefined) setInterno(valor);
onChange?.(valor);
}
function limpar() {
mudar("");
aoLimpar?.();
entrada.current?.focus();
}
return (
<div className="relative">
<input
{...props}
ref={entrada}
type="search"
enterKeyHint="search"
placeholder={placeholder}
value={atual}
aria-keyshortcuts={atalho ? "Control+K Meta+K" : props["aria-keyshortcuts"]}
onChange={(e) => mudar(e.target.value)}
onKeyDown={(e) => {
props.onKeyDown?.(e);
if (e.key === "Escape" && atual) {
e.preventDefault();
limpar();
}
}}
// O × nativo do navegador some: o botão de limpar é o nosso
className={`peer ${classesDoControle({ erro, comIcone: true, tamanho, folgaDireita: Boolean(atual) })} [&::-webkit-search-cancel-button]:appearance-none ${comDica ? "pr-16" : ""} ${className}`}
/>
<span aria-hidden className={`pointer-events-none absolute top-1/2 -translate-y-1/2 text-muted ${t.icone}`}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
</span>
{comDica && (
<kbd
aria-hidden
className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 rounded-md border border-rule bg-page px-1.5 py-0.5 font-sans text-[11px] font-medium text-muted peer-focus:hidden"
>
{mac ? "⌘K" : "Ctrl K"}
</kbd>
)}
{atual && (
<button
type="button"
onClick={limpar}
disabled={props.disabled}
aria-label={rotuloLimpar}
title={rotuloLimpar}
className={`${BOTAO_INTERNO} ${t.acao}`}
>
<svg aria-hidden viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
)}
</div>
);
}
/** Marca lida pelo Campo: a lupa já ocupa a esquerda, então ele não abre espaço para outro ícone. */
BarraDeBusca.temLupa = true;
import { useState } from "react";
import { Campo, type TamanhoDoCampo } from "@/library/campo-de-formulario/Campo";
import { defineStories } from "@/registry/stories";
import { BarraDeBusca } from "./BarraDeBusca";
const PRODUTOS = [
"Cadeira de escritório",
"Mesa regulável",
"Monitor 27 polegadas",
"Teclado sem fio",
"Mouse vertical",
"Suporte para notebook",
];
function ExemploComResultados() {
const [busca, setBusca] = useState("");
const termo = busca.trim().toLowerCase();
const achados = PRODUTOS.filter((p) => p.toLowerCase().includes(termo));
return (
<div className="w-full max-w-sm">
<BarraDeBusca
aria-label="Buscar produtos"
aria-controls="produtos-encontrados"
value={busca}
onChange={setBusca}
placeholder="Buscar produtos..."
/>
<p role="status" className="mt-3 text-xs text-muted">
{achados.length === PRODUTOS.length
? `${PRODUTOS.length} produtos`
: `${achados.length} de ${PRODUTOS.length} produtos`}
</p>
<ul id="produtos-encontrados" className="mt-2 divide-y divide-rule text-sm">
{achados.map((p) => (
<li key={p} className="py-2">
{p}
</li>
))}
</ul>
</div>
);
}
export default defineStories({
args: { placeholder: "Buscar por nome, tema, projeto...", tamanho: "md", atalho: true, desativado: false },
controls: {
placeholder: { type: "text" },
tamanho: { type: "select", options: ["sm", "md", "lg"] },
atalho: { type: "boolean", label: "atalho (Ctrl+K)" },
desativado: { type: "boolean", label: "disabled" },
},
render: (a) => (
<div className="w-full max-w-xl">
<BarraDeBusca
aria-label="Buscar"
placeholder={a.placeholder as string}
tamanho={a.tamanho as TamanhoDoCampo}
atalho={a.atalho as boolean}
disabled={a.desativado as boolean}
/>
</div>
),
stories: [
{ name: "Barra de busca" },
{ name: "Filtrando uma lista", render: () => <ExemploComResultados /> },
{
name: "Dentro de um Campo",
render: () => (
<div className="w-full max-w-sm">
<Campo rotulo="Cliente" ajuda="Busque pelo nome ou CNPJ.">
<BarraDeBusca placeholder="Buscar cliente..." />
</Campo>
</div>
),
},
{
name: "Tamanhos",
render: () => (
<div className="flex w-full max-w-sm flex-col gap-4">
{(["sm", "md", "lg"] as const).map((t) => (
<BarraDeBusca key={t} aria-label={`Busca (${t})`} tamanho={t} valorInicial="relatório" />
))}
</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.
Componentes da biblioteca (diretos e indiretos)
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);
}