extraído de nexco
Botão Flutuante
Botão redondo preso a um canto da tela (WhatsApp, chat, telefone, voltar ao topo ou ícone próprio). Vira link ou botão conforme as props e tem nome acessível obrigatório.
Variações
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<BotaoFlutuante>
Botão redondo flutuante, preso a um canto da tela: atalho para o WhatsApp, um chat, uma ligação ou voltar ao topo. Vira <a> com `href` e <button> com `aoClicar`. Só ícone, então o `rotulo` é obrigatório (é o nome acessível).
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| rotuloobrigatória | string | — | Nome acessível do botão (aria-label) e dica ao passar o mouse. |
| href | string | — | Destino do link. Sem ele, o componente vira um <button>. |
| aoClicar | () => void | — | |
| novaAba | boolean | — | Abre o link em nova aba. Padrão: sim para links http(s) externos. |
| icone | string | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | "whatsapp" | Ícone pronto ("whatsapp", "mensagem", "telefone", "topo") ou um ícone próprio (ReactNode). |
| tom | "superficie" | "sucesso" | "destaque" | "contraste" | "destaque" | Cor do botão: "destaque", "sucesso" (verde, bom para WhatsApp), "contraste" ou "superficie". |
| posicao | "inferior-direita" | "inferior-esquerda" | "inferior-direita" | Canto da tela em que o botão fica (com `fixo`). |
| fixo | boolean | true | Preso ao canto da tela. Com `false`, fica no fluxo normal da página. |
Código
stories.tsx tem os exemplos usados no preview e no playground.
botao-flutuante/BotaoFlutuante.tsx
"use client";
import type { ReactNode } from "react";
// Ícones prontos. O do WhatsApp é preenchido (vem do original); os outros, de traço.
const ICONES = {
whatsapp: (
<svg aria-hidden="true" viewBox="0 0 24 24" className="size-7" fill="currentColor">
<path d="M12.04 2C6.58 2 2.13 6.45 2.13 11.91c0 1.75.46 3.45 1.32 4.95L2 22l5.25-1.38a9.9 9.9 0 0 0 4.79 1.22h.01c5.46 0 9.91-4.45 9.91-9.91 0-2.65-1.03-5.14-2.9-7.01A9.82 9.82 0 0 0 12.04 2Zm0 18.15h-.01a8.2 8.2 0 0 1-4.19-1.15l-.3-.18-3.12.82.83-3.04-.2-.31a8.22 8.22 0 0 1-1.26-4.38c0-4.54 3.7-8.24 8.25-8.24 2.2 0 4.27.86 5.83 2.42a8.19 8.19 0 0 1 2.41 5.83c0 4.54-3.7 8.23-8.24 8.23Zm4.52-6.16c-.25-.13-1.47-.72-1.69-.81-.23-.08-.39-.12-.56.13-.16.24-.64.8-.78.97-.15.16-.29.18-.53.06-.25-.13-1.05-.39-1.99-1.23-.74-.66-1.23-1.47-1.38-1.71-.14-.25-.01-.38.11-.5.11-.11.25-.29.37-.44.13-.15.17-.25.25-.41.08-.17.04-.31-.02-.44-.06-.12-.56-1.34-.76-1.84-.2-.48-.4-.42-.56-.42h-.47c-.17 0-.44.06-.67.31-.23.25-.88.86-.88 2.1s.9 2.44 1.03 2.6c.12.17 1.77 2.71 4.3 3.8.6.26 1.07.42 1.44.53.6.2 1.15.17 1.58.1.48-.07 1.47-.6 1.68-1.19.21-.58.21-1.08.15-1.18-.06-.11-.23-.17-.47-.29Z" />
</svg>
),
mensagem: (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="size-6"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 12a8 8 0 0 1-11.6 7.1L4 20.5l1.4-4.9A8 8 0 1 1 21 12Z" />
<path d="M8.5 12h.01M12 12h.01M15.5 12h.01" />
</svg>
),
telefone: (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="size-6"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2Z" />
</svg>
),
topo: (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="size-6"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 19V5" />
<path d="m5 12 7-7 7 7" />
</svg>
),
};
export type IconeDoBotao = keyof typeof ICONES;
const TONS = {
destaque: "bg-accent text-page",
sucesso: "bg-success text-page",
contraste: "bg-ink text-page",
superficie: "border border-rule bg-surface text-ink",
} as const;
export type TomDoBotao = keyof typeof TONS;
const POSICOES = {
"inferior-direita": "fixed right-6 bottom-6 z-40",
"inferior-esquerda": "fixed bottom-6 left-6 z-40",
} as const;
const ehIconePronto = (icone: unknown): icone is IconeDoBotao =>
typeof icone === "string" && Object.hasOwn(ICONES, icone);
/**
* Botão redondo flutuante, preso a um canto da tela: atalho para o WhatsApp,
* um chat, uma ligação ou voltar ao topo. Vira <a> com `href` e <button> com
* `aoClicar`. Só ícone, então o `rotulo` é obrigatório (é o nome acessível).
*/
export function BotaoFlutuante({
rotulo,
href,
aoClicar,
novaAba,
icone = "whatsapp",
tom = "destaque",
posicao = "inferior-direita",
fixo = true,
}: {
/** Nome acessível do botão (aria-label) e dica ao passar o mouse. */
rotulo: string;
/** Destino do link. Sem ele, o componente vira um <button>. */
href?: string;
aoClicar?: () => void;
/** Abre o link em nova aba. Padrão: sim para links http(s) externos. */
novaAba?: boolean;
/** Ícone pronto ("whatsapp", "mensagem", "telefone", "topo") ou um ícone próprio (ReactNode). */
icone?: IconeDoBotao | ReactNode;
/** Cor do botão: "destaque", "sucesso" (verde, bom para WhatsApp), "contraste" ou "superficie". */
tom?: TomDoBotao;
/** Canto da tela em que o botão fica (com `fixo`). */
posicao?: keyof typeof POSICOES;
/** Preso ao canto da tela. Com `false`, fica no fluxo normal da página. */
fixo?: boolean;
}) {
const classes = `grid size-14 shrink-0 cursor-pointer place-items-center rounded-full shadow-flutuante outline-accent transition-transform focus-visible:outline-2 focus-visible:outline-offset-4 motion-safe:hover:scale-105 ${
TONS[tom]
} ${fixo ? POSICOES[posicao] : "relative"}`;
const conteudo = ehIconePronto(icone) ? ICONES[icone] : icone;
if (href) {
const abrirEmNovaAba = novaAba ?? /^https?:\/\//.test(href);
return (
<a
href={href}
onClick={aoClicar}
aria-label={rotulo}
title={rotulo}
className={classes}
{...(abrirEmNovaAba ? { target: "_blank", rel: "noopener noreferrer" } : {})}
>
{conteudo}
</a>
);
}
return (
<button type="button" onClick={aoClicar} aria-label={rotulo} title={rotulo} className={classes}>
{conteudo}
</button>
);
}
botao-flutuante/stories.tsx
import { defineStories } from "@/registry/stories";
import { BotaoFlutuante, type IconeDoBotao, type TomDoBotao } from "./BotaoFlutuante";
const ICONES: IconeDoBotao[] = ["whatsapp", "mensagem", "telefone", "topo"];
const TONS: TomDoBotao[] = ["destaque", "sucesso", "contraste", "superficie"];
const ROTULOS: Record<IconeDoBotao, string> = {
whatsapp: "Falar no WhatsApp",
mensagem: "Abrir o chat",
telefone: "Ligar para a central",
topo: "Voltar ao topo",
};
function PaginaDeExemplo({ texto }: { texto: string }) {
return (
<div className="flex min-h-80 w-full max-w-xl flex-col gap-3 rounded-2xl border border-rule p-6">
<p className="font-display text-xl font-bold">Página de exemplo</p>
<p className="text-sm text-ink-2">{texto}</p>
</div>
);
}
export default defineStories({
args: { rotulo: "Falar no WhatsApp", icone: "whatsapp", tom: "destaque", posicao: "inferior-direita", fixo: false },
controls: {
rotulo: { type: "text", label: "rotulo (aria-label)" },
icone: { type: "select", options: ICONES },
tom: { type: "select", options: TONS },
posicao: { type: "select", options: ["inferior-direita", "inferior-esquerda"] },
fixo: { type: "boolean" },
},
render: (a) => (
<BotaoFlutuante
rotulo={a.rotulo}
href="#whatsapp"
icone={a.icone as IconeDoBotao}
tom={a.tom as TomDoBotao}
posicao={a.posicao as "inferior-direita" | "inferior-esquerda"}
fixo={a.fixo}
/>
),
stories: [
{ name: "WhatsApp" },
{
name: "Ícones e tons",
render: () => (
<div className="flex flex-wrap items-center justify-center gap-4 p-4">
{ICONES.map((icone, i) => (
<BotaoFlutuante
key={icone}
rotulo={ROTULOS[icone]}
icone={icone}
tom={TONS[i]}
fixo={false}
aoClicar={() => {}}
/>
))}
</div>
),
},
{
name: "Fixo no canto",
render: () => (
<>
<PaginaDeExemplo texto="O botão fica preso no canto inferior direito do preview, por cima do conteúdo." />
<BotaoFlutuante rotulo="Falar no WhatsApp" href="#whatsapp" tom="sucesso" />
</>
),
},
{
name: "Voltar ao topo",
render: () => (
<>
<PaginaDeExemplo texto="Botão (não link) no canto inferior esquerdo que leva de volta ao topo." />
<BotaoFlutuante
rotulo="Voltar ao topo"
icone="topo"
tom="superficie"
posicao="inferior-esquerda"
aoClicar={() => window.scrollTo({ top: 0, behavior: "smooth" })}
/>
</>
),
},
],
});
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.css · Editorial
/* 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:
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);
}