Seletor de Cor
Amostras de cor prontas (redondas ou quadradas) com anel na escolhida e um botão para qualquer outra cor pelo seletor nativo. No modo compacto, uma bolinha com a cor atual abre a paleta num balão.
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<SeletorDeCor>
Escolha de cor por amostras prontas, com a opção de qualquer outra cor pelo seletor nativo do navegador. A amostra escolhida ganha um anel na cor de destaque; uma cor fora da paleta aparece como amostra extra, já marcada. No modo compacto, vira uma bolinha com a cor atual que abre a paleta num balão.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| coresobrigatória | string[] | — | As amostras, em qualquer formato CSS ("#1a56db", "rgb(...)", "var(--series-1)"). |
| valorobrigatória | string | — | |
| aoMudarobrigatória | (cor: string) => void | — | |
| rotulo | string | "Cor" | Nome do grupo de amostras (e do botão, no modo compacto) para leitores de tela. |
| formato | "redondo" | "quadrado" | "redondo" | |
| tamanho | "sm" | "md" | "lg" | "md" | |
| comPersonalizada | boolean | true | Mostra o botão arco-íris que abre o seletor nativo (devolve "#rrggbb"). |
| compacto | boolean | false | Só uma bolinha com a cor atual; a paleta abre num balão ao clicar. |
| nomeDaCor | (cor: string) => string | (cor) => `Cor ${cor}` | Nome de cada amostra para leitores de tela (padrão: "Cor #1a56db"). |
| rotuloPersonalizada | string | "Escolher outra cor" | |
| rotuloCorAtual | string | "Cor atual" | Prefixo do nome da amostra extra quando a cor atual está fora da paleta. |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
const TAMANHOS = { sm: "size-6", md: "size-8", lg: "size-10" } as const;
/** Arco-íris do botão de cor personalizada, com as cores categóricas do tema. */
const ARCO_IRIS =
"conic-gradient(var(--series-1), var(--series-2), var(--series-3), var(--series-4), var(--series-5), var(--series-6), var(--series-7), var(--series-8), var(--series-1))";
/** Distância (px) entre o gatilho e o balão, e margem mínima até a borda da janela. */
const DISTANCIA = 6;
const MARGEM = 8;
/**
* Converte "#abc", "#aabbcc" e "rgb(r, g, b)" no "#rrggbb" que o `<input type="color">`
* aceita. Outros formatos caem em preto (o seletor nativo só abre nessa cor).
*/
function paraHex(cor: string): string {
const texto = cor.trim().toLowerCase();
if (/^#[0-9a-f]{6}$/.test(texto)) return texto;
if (/^#[0-9a-f]{3}$/.test(texto)) return `#${[...texto.slice(1)].map((c) => c + c).join("")}`;
const rgb = texto.match(/^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/);
if (rgb) return `#${rgb.slice(1, 4).map((n) => Math.min(255, Number(n)).toString(16).padStart(2, "0")).join("")}`;
return "#000000";
}
const mesmaCor = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase();
type Visual = { formato: "redondo" | "quadrado"; tamanho: keyof typeof TAMANHOS };
function classesDaAmostra({ formato, tamanho }: Visual, selecionada: boolean) {
return `${TAMANHOS[tamanho]} ${formato === "redondo" ? "rounded-full" : "rounded-md"} shrink-0 cursor-pointer ring-1 ring-ink/15 ring-inset outline-offset-2 transition-transform motion-safe:hover:scale-110 focus-visible:outline-2 focus-visible:outline-ink ${
selecionada ? "outline-2 outline-accent" : ""
}`;
}
type Textos = { rotulo: string; personalizada: string; corAtual: string; nomeDaCor: (cor: string) => string };
/** A paleta em si: amostras, a cor atual quando é personalizada e o botão de outra cor. */
function Paleta({
cores,
valor,
visual,
comPersonalizada,
textos,
aoEscolher,
}: {
cores: string[];
valor: string;
visual: Visual;
comPersonalizada: boolean;
textos: Textos;
aoEscolher: (cor: string, origem: "amostra" | "personalizada") => void;
}) {
const personalizada = valor !== "" && !cores.some((c) => mesmaCor(c, valor));
return (
<div role="group" aria-label={textos.rotulo} className="flex flex-wrap items-center gap-2.5">
{/* Cor fora da paleta: aparece antes das amostras, sempre marcada */}
{personalizada && (
<button
type="button"
aria-pressed="true"
aria-label={`${textos.corAtual}: ${valor}`}
title={valor}
onClick={() => aoEscolher(valor, "amostra")}
className={classesDaAmostra(visual, true)}
style={{ background: valor }}
/>
)}
{cores.map((cor) => {
const selecionada = mesmaCor(cor, valor);
return (
<button
key={cor}
type="button"
aria-pressed={selecionada}
aria-label={textos.nomeDaCor(cor)}
title={textos.nomeDaCor(cor)}
onClick={() => aoEscolher(cor, "amostra")}
className={classesDaAmostra(visual, selecionada)}
style={{ background: cor }}
/>
);
})}
{comPersonalizada && (
// O seletor nativo fica invisível por cima do arco-íris: o clique cai direto nele
<label
title={textos.personalizada}
className={`${classesDaAmostra(visual, false)} relative grid place-items-center has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-ink`}
style={{ background: ARCO_IRIS }}
>
<span aria-hidden="true" className="grid size-1/2 min-h-3 min-w-3 place-items-center rounded-full bg-page text-ink">
<svg viewBox="0 0 12 12" className="size-2/3" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round">
<path d="M6 2.5v7M2.5 6h7" />
</svg>
</span>
<input
type="color"
aria-label={textos.personalizada}
value={paraHex(valor)}
onChange={(e) => aoEscolher(e.target.value, "personalizada")}
className="absolute inset-0 size-full cursor-pointer opacity-0"
/>
</label>
)}
</div>
);
}
/**
* Escolha de cor por amostras prontas, com a opção de qualquer outra cor pelo
* seletor nativo do navegador. A amostra escolhida ganha um anel na cor de
* destaque; uma cor fora da paleta aparece como amostra extra, já marcada. No modo
* compacto, vira uma bolinha com a cor atual que abre a paleta num balão.
*/
export function SeletorDeCor({
cores,
valor,
aoMudar,
rotulo = "Cor",
formato = "redondo",
tamanho = "md",
comPersonalizada = true,
compacto = false,
nomeDaCor = (cor) => `Cor ${cor}`,
rotuloPersonalizada = "Escolher outra cor",
rotuloCorAtual = "Cor atual",
}: {
/** As amostras, em qualquer formato CSS ("#1a56db", "rgb(...)", "var(--series-1)"). */
cores: string[];
valor: string;
aoMudar: (cor: string) => void;
/** Nome do grupo de amostras (e do botão, no modo compacto) para leitores de tela. */
rotulo?: string;
formato?: "redondo" | "quadrado";
tamanho?: "sm" | "md" | "lg";
/** Mostra o botão arco-íris que abre o seletor nativo (devolve "#rrggbb"). */
comPersonalizada?: boolean;
/** Só uma bolinha com a cor atual; a paleta abre num balão ao clicar. */
compacto?: boolean;
/** Nome de cada amostra para leitores de tela (padrão: "Cor #1a56db"). */
nomeDaCor?: (cor: string) => string;
rotuloPersonalizada?: string;
/** Prefixo do nome da amostra extra quando a cor atual está fora da paleta. */
rotuloCorAtual?: string;
}) {
const visual: Visual = { formato, tamanho };
const textos: Textos = { rotulo, personalizada: rotuloPersonalizada, corAtual: rotuloCorAtual, nomeDaCor };
if (!compacto) {
return (
<Paleta
cores={cores}
valor={valor}
visual={visual}
comPersonalizada={comPersonalizada}
textos={textos}
aoEscolher={(cor) => aoMudar(cor)}
/>
);
}
return (
<SeletorCompacto
cores={cores}
valor={valor}
aoMudar={aoMudar}
visual={visual}
comPersonalizada={comPersonalizada}
textos={textos}
/>
);
}
/** Modo compacto: a bolinha e o balão com a paleta, num portal (não é cortado por contêineres com rolagem). */
function SeletorCompacto({
cores,
valor,
aoMudar,
visual,
comPersonalizada,
textos,
}: {
cores: string[];
valor: string;
aoMudar: (cor: string) => void;
visual: Visual;
comPersonalizada: boolean;
textos: Textos;
}) {
const id = useId();
const [alvo, setAlvo] = useState<HTMLElement | null>(null);
const botao = useRef<HTMLButtonElement>(null);
const balao = useRef<HTMLDivElement>(null);
const aberto = alvo !== null;
function abrir() {
const elemento = botao.current;
if (!elemento) return;
// O portal vai para o ancestral com data-theme (onde moram as variáveis do tema) ou para o <body>
const tema = elemento.closest<HTMLElement>("[data-theme]");
setAlvo(tema && tema !== document.documentElement ? tema : document.body);
}
const posicionar = useCallback(() => {
const b = botao.current;
const p = balao.current;
if (!b || !p) return;
const caixa = b.getBoundingClientRect();
const largura = p.offsetWidth;
const altura = p.offsetHeight;
const left = Math.max(MARGEM, Math.min(caixa.left, window.innerWidth - largura - MARGEM));
const sobe = window.innerHeight - caixa.bottom - DISTANCIA < altura + MARGEM && caixa.top > altura + DISTANCIA + MARGEM;
p.style.left = `${left}px`;
p.style.top = `${sobe ? caixa.top - DISTANCIA - altura : caixa.bottom + DISTANCIA}px`;
}, []);
// Antes de pintar: posiciona (de novo quando a paleta muda de tamanho) e foca a amostra marcada
useLayoutEffect(() => {
if (aberto) posicionar();
}, [aberto, posicionar, valor]);
useLayoutEffect(() => {
if (!aberto) return;
const p = balao.current;
const marcada = p?.querySelector<HTMLElement>("[aria-pressed='true']") ?? p?.querySelector<HTMLElement>("button, input");
marcada?.focus({ preventScroll: true });
}, [aberto]);
useEffect(() => {
if (!aberto) return;
const fora = (e: PointerEvent) => {
const t = e.target as Node;
if (balao.current?.contains(t) || botao.current?.contains(t)) return;
setAlvo(null);
};
// Na captura da janela: o Esc fecha só o balão e não chega a um modal aberto por trás
const esc = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
e.preventDefault();
e.stopPropagation();
setAlvo(null);
botao.current?.focus();
};
const fechar = () => setAlvo(null);
document.addEventListener("pointerdown", fora);
window.addEventListener("keydown", esc, true);
window.addEventListener("scroll", fechar, true);
window.addEventListener("resize", fechar);
return () => {
document.removeEventListener("pointerdown", fora);
window.removeEventListener("keydown", esc, true);
window.removeEventListener("scroll", fechar, true);
window.removeEventListener("resize", fechar);
};
}, [aberto]);
return (
<>
<button
ref={botao}
type="button"
aria-haspopup="dialog"
aria-expanded={aberto}
aria-controls={aberto ? id : undefined}
aria-label={`${textos.rotulo}: ${valor || "nenhuma"}`}
title={textos.rotulo}
onClick={() => (aberto ? setAlvo(null) : abrir())}
className={classesDaAmostra(visual, aberto)}
style={{ background: valor }}
/>
{alvo &&
createPortal(
<div
ref={balao}
id={id}
role="dialog"
aria-label={textos.rotulo}
className="fixed z-50 w-max max-w-[min(16rem,calc(100vw-1rem))] rounded-xl border border-rule bg-page p-3 shadow-flutuante"
>
<Paleta
cores={cores}
valor={valor}
visual={visual}
comPersonalizada={comPersonalizada}
textos={textos}
aoEscolher={(cor, origem) => {
aoMudar(cor);
// Amostra pronta fecha o balão; no seletor nativo a pessoa ainda está arrastando
if (origem === "amostra") {
setAlvo(null);
botao.current?.focus();
}
}}
/>
</div>,
alvo,
)}
</>
);
}
import { useState } from "react";
import { defineStories } from "@/registry/stories";
import { SeletorDeCor } from "./SeletorDeCor";
// Hex aqui é dado de exemplo: as cores que a pessoa escolhe (o componente em si só usa tokens)
const PALETA = [
"#2563eb",
"#0ea5e9",
"#14b8a6",
"#22c55e",
"#eab308",
"#f97316",
"#ef4444",
"#ec4899",
"#a855f7",
"#64748b",
];
const NOMES: Record<string, string> = {
"#2563eb": "Azul",
"#0ea5e9": "Céu",
"#14b8a6": "Turquesa",
"#22c55e": "Verde",
"#eab308": "Amarelo",
"#f97316": "Laranja",
"#ef4444": "Vermelho",
"#ec4899": "Rosa",
"#a855f7": "Roxo",
"#64748b": "Ardósia",
};
const nomeDaCor = (cor: string) => NOMES[cor] ?? cor;
type Formato = "redondo" | "quadrado";
type Tamanho = "sm" | "md" | "lg";
function Exemplo({
formato,
tamanho,
comPersonalizada,
compacto,
inicial = PALETA[0],
}: {
formato: Formato;
tamanho: Tamanho;
comPersonalizada: boolean;
compacto: boolean;
inicial?: string;
}) {
const [cor, setCor] = useState(inicial);
return (
<div className={`flex w-full max-w-md flex-col gap-4 ${compacto ? "min-h-64" : ""}`}>
<div className="flex items-center gap-3">
<SeletorDeCor
cores={PALETA}
valor={cor}
aoMudar={setCor}
rotulo="Cor da etiqueta"
formato={formato}
tamanho={tamanho}
comPersonalizada={comPersonalizada}
compacto={compacto}
nomeDaCor={nomeDaCor}
/>
{compacto && <span className="text-sm text-ink-2">Cor da etiqueta</span>}
</div>
<p className="flex items-center gap-2 text-sm text-muted">
<span aria-hidden="true" className="size-3 rounded-full" style={{ background: cor }} />
{NOMES[cor] ?? "Personalizada"} <code className="text-xs">{cor}</code>
</p>
</div>
);
}
/** Lista de etiquetas com uma bolinha de cor em cada linha (o modo compacto do original). */
function ExemploEmLista() {
const [cores, setCores] = useState({ Urgente: "#ef4444", Financeiro: "#22c55e", Marketing: "#a855f7" });
return (
// min-h: espaço para o balão abrir embaixo do último item
<div className="min-h-72 w-full max-w-xs">
<ul className="flex flex-col divide-y divide-rule rounded-xl border border-rule">
{Object.entries(cores).map(([nome, cor]) => (
<li key={nome} className="flex items-center gap-3 px-4 py-3 text-sm">
<SeletorDeCor
compacto
tamanho="sm"
cores={PALETA}
valor={cor}
aoMudar={(nova) => setCores((atual) => ({ ...atual, [nome]: nova }))}
rotulo={`Cor de ${nome}`}
nomeDaCor={nomeDaCor}
/>
{nome}
</li>
))}
</ul>
</div>
);
}
export default defineStories({
args: { formato: "redondo", tamanho: "md", comPersonalizada: true, compacto: false },
controls: {
formato: { type: "select", options: ["redondo", "quadrado"] },
tamanho: { type: "select", options: ["sm", "md", "lg"] },
comPersonalizada: { type: "boolean", label: "botão de cor personalizada" },
compacto: { type: "boolean", label: "compacto (abre num balão)" },
},
render: (a) => (
<Exemplo
formato={a.formato as Formato}
tamanho={a.tamanho as Tamanho}
comPersonalizada={a.comPersonalizada}
compacto={a.compacto}
/>
),
stories: [
{ name: "Paleta" },
{ name: "Quadrado", args: { formato: "quadrado", tamanho: "lg" } },
{
name: "Cor personalizada",
render: () => <Exemplo formato="redondo" tamanho="md" comPersonalizada compacto={false} inicial="#0f766e" />,
},
{ name: "Compacto", args: { compacto: true } },
{ name: "Compacto numa lista", render: () => <ExemploEmLista /> },
],
});
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);
}