Navegação Flutuante
Barra de navegação em pílula, centralizada e flutuante sobre vidro. Uma pílula desliza sob o item em foco, o nome da marca colapsa ao rolar e, no celular, os links vão para um menu acessível.
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<NavegacaoFlutuante>
Barra de navegação flutuante em pílula, centralizada, sobre uma camada de vidro. Uma pílula desliza sob o item em que o mouse (ou o foco) está; ao rolar a página, o nome da marca colapsa e sobra só o logo. Abaixo de `lg`, os links vão para um menu (botão com aria-expanded; Esc ou clique fora fecham).
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| marcaobrigatória | MarcaDaNavegacao | — | |
| linksobrigatória | LinkDaNavegacao[] | — | |
| acao | string | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | — | Chamada à direita da barra (um botão ou link de contato, por exemplo). Fica fora da pílula. |
| atual | string | — | `href` do link da página atual: recebe aria-current="page" e fica destacado. |
| colapsarApos | number | false | 80 | Rolagem (px) a partir da qual o nome da marca colapsa e sobra só o logo. `false` desliga. Sem logo, não colapsa. |
| fixa | boolean | true | Fixa no topo da tela, centralizada. Com `false`, fica no fluxo normal da página. |
| rotulo | string | "Navegação principal" | Nome da navegação para leitores de tela (aria-label do <nav>). |
| rotuloDoMenu | string | "Menu" | Rótulo do botão que abre os links no celular. |
Código
stories.tsx tem os exemplos usados no preview e no playground.
/* Entrada da barra: desce e assenta. Em CSS para o primeiro quadro já sair certo
antes da hidratação; quem pede menos movimento não recebe animação. */
@media (prefers-reduced-motion: no-preference) {
.entrada {
animation: desce 900ms cubic-bezier(0.16, 1, 0.3, 1) 150ms both;
}
}
/* useSemMovimento() (ProvedorDeMovimento, miniaturas do catálogo): estado final direto. */
.parado {
animation: none;
}
@keyframes desce {
from {
opacity: 0;
transform: translateY(-28px);
}
to {
opacity: 1;
transform: none;
}
}
"use client";
import { useEffect, useId, useRef, useState, useSyncExternalStore, type FocusEvent, type ReactNode } from "react";
import { CURVA_SUAVE, useSemMovimento } from "@/library/revelar/Movimento";
import estilos from "./NavegacaoFlutuante.module.css";
export type LinkDaNavegacao = { rotulo: string; href: string };
export type MarcaDaNavegacao = {
nome: string;
/** Símbolo da marca (SVG, <img>...). É o que sobra quando o nome colapsa. */
logo?: ReactNode;
href: string;
};
type Pilula = { x: number; y: number; largura: number; visivel: boolean; saltar: boolean };
function assinarRolagem(aviso: () => void) {
window.addEventListener("scroll", aviso, { passive: true });
return () => window.removeEventListener("scroll", aviso);
}
const FOCO = "outline-accent focus-visible:outline-2 focus-visible:outline-offset-2";
/**
* Barra de navegação flutuante em pílula, centralizada, sobre uma camada de
* vidro. Uma pílula desliza sob o item em que o mouse (ou o foco) está; ao rolar
* a página, o nome da marca colapsa e sobra só o logo. Abaixo de `lg`, os links
* vão para um menu (botão com aria-expanded; Esc ou clique fora fecham).
*/
export function NavegacaoFlutuante({
marca,
links,
acao,
atual,
colapsarApos = 80,
fixa = true,
rotulo = "Navegação principal",
rotuloDoMenu = "Menu",
}: {
marca: MarcaDaNavegacao;
links: LinkDaNavegacao[];
/** Chamada à direita da barra (um botão ou link de contato, por exemplo). Fica fora da pílula. */
acao?: ReactNode;
/** `href` do link da página atual: recebe aria-current="page" e fica destacado. */
atual?: string;
/** Rolagem (px) a partir da qual o nome da marca colapsa e sobra só o logo. `false` desliga. Sem logo, não colapsa. */
colapsarApos?: number | false;
/** Fixa no topo da tela, centralizada. Com `false`, fica no fluxo normal da página. */
fixa?: boolean;
/** Nome da navegação para leitores de tela (aria-label do <nav>). */
rotulo?: string;
/** Rótulo do botão que abre os links no celular. */
rotuloDoMenu?: string;
}) {
const semMovimento = useSemMovimento();
const rolou = useSyncExternalStore(
assinarRolagem,
() => colapsarApos !== false && window.scrollY > colapsarApos,
() => false,
);
const colapsada = Boolean(marca.logo) && rolou;
const [pilula, setPilula] = useState<Pilula>({ x: 0, y: 0, largura: 0, visivel: false, saltar: true });
const itemSobAPilula = useRef<HTMLElement | null>(null);
const [aberto, setAberto] = useState(false);
const nav = useRef<HTMLElement>(null);
const botaoDoMenu = useRef<HTMLButtonElement>(null);
const idDoPainel = useId();
// A pílula se mede pelo item: offsetLeft/offsetWidth relativos à barra.
const mover = (item: HTMLElement) => {
itemSobAPilula.current = item;
setPilula((p) => ({
x: item.offsetLeft,
y: item.offsetTop,
largura: item.offsetWidth,
visivel: true,
// Vindo de invisível, aparece já no lugar em vez de deslizar de onde parou.
saltar: !p.visivel,
}));
};
const esconder = () => {
itemSobAPilula.current = null;
setPilula((p) => ({ ...p, visivel: false }));
};
// Ao tirar o mouse, a pílula volta para o item com foco de teclado, se houver.
const aoSairDoMouse = () => {
const foco = document.activeElement;
if (foco instanceof HTMLElement && foco.dataset.pilula !== undefined && foco.matches(":focus-visible")) mover(foco);
else esconder();
};
const aoSairDoFoco = (e: FocusEvent<HTMLElement>) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) esconder();
};
// Enquanto o nome da marca colapsa, os itens andam: a pílula acompanha no fim.
const remedir = () => {
if (itemSobAPilula.current) mover(itemSobAPilula.current);
};
useEffect(() => {
if (!aberto) return;
const fora = (e: PointerEvent) => {
if (!nav.current?.contains(e.target as Node)) setAberto(false);
};
const esc = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
setAberto(false);
botaoDoMenu.current?.focus();
};
document.addEventListener("pointerdown", fora);
document.addEventListener("keydown", esc);
return () => {
document.removeEventListener("pointerdown", fora);
document.removeEventListener("keydown", esc);
};
}, [aberto]);
const eventosDaPilula = {
"data-pilula": "",
onMouseEnter: (e: { currentTarget: HTMLElement }) => mover(e.currentTarget),
onFocus: (e: { currentTarget: HTMLElement }) => mover(e.currentTarget),
};
return (
<div
className={
fixa
? "pointer-events-none fixed inset-x-0 top-4 z-40 flex justify-center px-4 lg:top-6"
: "relative z-40 flex justify-center px-4 py-1.5"
}
>
<nav
ref={nav}
aria-label={rotulo}
className={`pointer-events-auto relative max-w-full ${estilos.entrada} ${semMovimento ? estilos.parado : ""}`}
>
<div className="relative flex max-w-full items-center" onMouseLeave={aoSairDoMouse} onBlur={aoSairDoFoco}>
{/* Camada de vidro, um pouco maior que o grupo de itens. */}
<span
aria-hidden="true"
className="pointer-events-none absolute -inset-1.5 rounded-3xl border border-rule bg-page/70 shadow-flutuante backdrop-blur-md"
/>
{/* Pílula que desliza sob o item em foco ou sob o mouse. */}
<span
aria-hidden="true"
className="pointer-events-none absolute top-0 left-0 h-11 rounded-[max(var(--radius-sm),calc(var(--radius-3xl)_-_var(--spacing)*1.5))] bg-ink/10"
style={{
width: pilula.largura,
transform: `translate(${pilula.x}px, ${pilula.y}px)`,
opacity: pilula.visivel ? 1 : 0,
transition:
semMovimento || pilula.saltar
? "opacity 250ms ease-out"
: `transform 350ms ${CURVA_SUAVE}, width 350ms ${CURVA_SUAVE}, opacity 250ms ease-out`,
}}
/>
<a
href={marca.href}
aria-current={atual === marca.href ? "page" : undefined}
className={`relative z-10 flex h-11 min-w-0 items-center rounded-[max(var(--radius-sm),calc(var(--radius-3xl)_-_var(--spacing)*1.5))] px-3 text-ink ${FOCO}`}
{...eventosDaPilula}
>
{marca.logo && (
<span aria-hidden="true" className="flex shrink-0 items-center">
{marca.logo}
</span>
)}
{/* O nome colapsa animando a coluna do grid de 1fr para 0fr: sem medir a largura. Continua
no DOM (só fica sem largura), então segue sendo o nome acessível do link. */}
<span
className={`grid min-w-0 ${colapsada ? "grid-cols-[0fr] opacity-0" : "grid-cols-[1fr] opacity-100"}`}
style={{
transition: semMovimento
? undefined
: "grid-template-columns 450ms cubic-bezier(0.65, 0, 0.35, 1), opacity 450ms cubic-bezier(0.65, 0, 0.35, 1)",
}}
onTransitionEnd={remedir}
>
{/* Em telas estreitas o nome encolhe com reticências em vez de empurrar a barra para fora */}
<span className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
<span className={`pr-1 text-sm font-semibold tracking-tight ${marca.logo ? "pl-2" : ""}`}>
{marca.nome}
</span>
</span>
</span>
</a>
<ul className="hidden items-center lg:flex">
{links.map((l) => (
<li key={l.href}>
<a
href={l.href}
aria-current={atual === l.href ? "page" : undefined}
className={`relative z-10 flex h-11 items-center rounded-[max(var(--radius-sm),calc(var(--radius-3xl)_-_var(--spacing)*1.5))] px-3.5 text-sm whitespace-nowrap transition-colors duration-300 ${FOCO} ${
atual === l.href ? "font-medium text-ink" : "text-ink-2 hover:text-ink"
}`}
{...eventosDaPilula}
>
{l.rotulo}
</a>
</li>
))}
</ul>
{acao && (
<div className="relative z-10 ml-1.5 flex shrink-0 items-center" onMouseEnter={esconder}>
{acao}
</div>
)}
{links.length > 0 && (
<button
ref={botaoDoMenu}
type="button"
aria-expanded={aberto}
aria-controls={idDoPainel}
aria-label={rotuloDoMenu}
onClick={() => setAberto((a) => !a)}
onMouseEnter={esconder}
className={`relative z-10 ml-1 grid size-11 shrink-0 cursor-pointer place-items-center rounded-[max(var(--radius-sm),calc(var(--radius-3xl)_-_var(--spacing)*1.5))] text-ink transition-colors hover:bg-ink/10 lg:hidden ${FOCO}`}
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="size-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
>
{aberto ? (
<path d="M6 6l12 12M18 6 6 18" />
) : (
<>
<path d="M4 7h16" />
<path d="M4 12h16" />
<path d="M4 17h16" />
</>
)}
</svg>
</button>
)}
</div>
{/* Menu do celular: some no lg, onde os links já estão na barra. */}
<div
id={idDoPainel}
hidden={!aberto}
className="absolute top-full left-1/2 mt-4 w-[min(22rem,calc(100vw-2rem))] -translate-x-1/2 rounded-3xl border border-rule bg-page p-2 shadow-flutuante lg:hidden"
>
<ul className="flex flex-col">
{links.map((l) => (
<li key={l.href}>
<a
href={l.href}
aria-current={atual === l.href ? "page" : undefined}
onClick={() => setAberto(false)}
className={`flex min-h-11 items-center rounded-[max(var(--radius-sm),calc(var(--radius-3xl)_-_var(--spacing)*2))] px-4 text-base transition-colors hover:bg-ink/10 hover:text-ink ${FOCO} ${
atual === l.href ? "font-medium text-ink" : "text-ink-2"
}`}
>
{l.rotulo}
</a>
</li>
))}
</ul>
</div>
</nav>
</div>
);
}
import { defineStories } from "@/registry/stories";
import { NavegacaoFlutuante } from "./NavegacaoFlutuante";
const MARCA = { nome: "Painel Aurora", href: "#inicio" };
const LINKS = [
{ rotulo: "Início", href: "#inicio" },
{ rotulo: "Recursos", href: "#recursos" },
{ rotulo: "Preços", href: "#precos" },
{ rotulo: "Clientes", href: "#clientes" },
{ rotulo: "Blog", href: "#blog" },
{ rotulo: "Sobre", href: "#sobre" },
];
function Logo() {
return (
<svg viewBox="0 0 24 24" className="size-6">
<circle cx="9" cy="12" r="6.5" fill="var(--series-1)" />
<circle cx="15" cy="12" r="6.5" fill="var(--series-3)" fillOpacity={0.85} />
</svg>
);
}
function Contato() {
return (
<a
href="#contato"
className="flex h-11 items-center rounded-2xl bg-accent px-5 text-sm font-medium whitespace-nowrap text-page outline-accent transition-opacity hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2"
>
Contato
</a>
);
}
// Fundo colorido atrás da barra, para o vidro aparecer.
const FAIXA_COLORIDA = {
background:
"radial-gradient(60% 120% at 20% 0%, var(--series-1), transparent 70%), radial-gradient(50% 120% at 85% 100%, var(--series-5), transparent 70%), var(--surface)",
};
/** Página de mentira para a barra fixa ter o que rolar. */
function PaginaParaRolar() {
return (
<div className="w-full">
<div className="flex h-[70vh] items-end rounded-3xl p-8" style={FAIXA_COLORIDA}>
<p className="max-w-md font-display text-3xl font-bold">
Role para baixo: o nome da marca colapsa e sobra só o logo.
</p>
</div>
<div className="mt-10 grid gap-4 sm:grid-cols-3">
{["Metas", "Funil", "Relatórios"].map((t) => (
<div key={t} className="h-48 rounded-2xl bg-surface p-6">
<p className="font-display text-xl font-bold">{t}</p>
<p className="mt-2 text-sm text-ink-2">Conteúdo de exemplo para a página rolar.</p>
</div>
))}
</div>
<div className="h-[120vh]" />
</div>
);
}
export default defineStories({
args: { nome: "Painel Aurora", links: 6, comLogo: true, comAcao: true, atual: "", colapsarApos: 80, fixa: false },
controls: {
nome: { type: "text", label: "marca.nome" },
links: { type: "number", label: "links (quantidade)", min: 0, max: 6 },
comLogo: { type: "boolean", label: "marca.logo (sem logo, o nome não colapsa)" },
comAcao: { type: "boolean", label: "acao (botão Contato)" },
atual: { type: "select", options: ["", ...LINKS.map((l) => l.href)] },
colapsarApos: { type: "number", label: "colapsarApos (px)", min: 0, max: 600, step: 20 },
fixa: { type: "boolean" },
},
render: (a) => (
<div className="w-full">
<div className={a.fixa ? "" : "min-h-80 rounded-3xl p-6"} style={a.fixa ? undefined : FAIXA_COLORIDA}>
<NavegacaoFlutuante
marca={{ ...MARCA, nome: a.nome, logo: a.comLogo ? <Logo /> : undefined }}
links={LINKS.slice(0, a.links)}
acao={a.comAcao ? <Contato /> : undefined}
atual={a.atual || undefined}
colapsarApos={a.colapsarApos}
fixa={a.fixa}
/>
</div>
{a.fixa && (
<div className="pt-20">
<PaginaParaRolar />
</div>
)}
</div>
),
stories: [
{ name: "No fluxo da página" },
{ name: "Fixa, ao rolar", args: { fixa: true } },
{ name: "Página atual marcada", args: { atual: "#precos" } },
{ name: "Sem logo e sem chamada", args: { comLogo: false, comAcao: false, links: 4 } },
],
});
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);
}