Swatch fan
Colour picker as a paint fan deck.
Code
1. Install
# No packages beyond React.
2. Files
"use client";
// SwatchFan: a paint fan deck. At rest it peeks, the chosen card pulled out on top. Drag the deck open (the card under
// your finger follows it), hover slides a card out, tap one and the fan closes onto it. Arrows browse, Enter picks.
import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent as RKE, type PointerEvent as RPE } from "react";
export type SwatchFanCue = "pip" | "detent" | "choose" | "grab" | "unfold" | "fold" | "place";
export type Swatch = { name: string; hex: string };
export type SwatchFanLabels = {
/** Accessible name of the deck. */
deck: string;
/** Accessible name and live announcement of a swatch, e.g. "Clay, C0704B". */
swatch: (name: string, hex: string) => string;
/** Shown in place of the deck when there are no swatches. */
empty: string;
};
export const DEFAULT_SWATCHFAN_LABELS: SwatchFanLabels = {
deck: "Paint colour",
swatch: (name, hex) => `${name}, ${hex}`,
empty: "No swatches",
};
// paint chips are content, not chrome: real colours, the same in light and dark
export const DEFAULT_SWATCHES: Swatch[] = [
{ name: "Chalk", hex: "#EAE4D8" },
{ name: "Sand", hex: "#D8BF97" },
{ name: "Clay", hex: "#C0704B" },
{ name: "Brick", hex: "#9E3B2E" },
{ name: "Moss", hex: "#6E7A45" },
{ name: "Sea glass", hex: "#8DB1A4" },
{ name: "Harbour", hex: "#3D5A73" },
{ name: "Ink", hex: "#24272E" },
];
export type SwatchFanProps = {
/** Cards in the deck; hex is a 6-digit colour such as "#C0704B". */
swatches?: Swatch[];
/** Hex of the chosen swatch (controlled). */
value?: string;
/** Hex of the chosen swatch at mount (uncontrolled); the third card when omitted. */
defaultValue?: string;
/** Called with the swatch picked. */
onChange?: (s: Swatch) => void;
/** Visible and screen-reader text; any key you leave out keeps its default. */
labels?: Partial<SwatchFanLabels>;
/** Sound hook: "grab" when a drag starts, "detent" per card passed (at = position), "unfold" / "fold" as the fan opens or shuts, "choose" picking (at = position), "pip" per key step, "place" on release. */
onSound?: (cue: SwatchFanCue, opts?: { at?: number }) => void;
/** Degrees between cards when the fan is fully open; shrinks on its own past 8 cards so the deck keeps its footprint. */
spread?: number;
/** Degrees between cards at rest, so the deck peeks; shrinks on its own past 8 cards. */
peek?: number;
/** 0..100 how much the fan overshoots when it opens and closes. */
bounce?: number;
className?: string;
style?: CSSProperties;
};
const cn = (...a: (string | false | null | undefined)[]) => a.filter(Boolean).join(" ");
const clamp = (n: number, a: number, b: number) => Math.min(b, Math.max(a, n));
const prefersReducedMotion = () => typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
const PIN = { x: 28, y: 174 };
const REST = 22; // angle of the chosen card at rest
const OPEN0 = 3; // angle of the bottom card when open
const OUT = 9; // how far a hovered card slides out of the deck
const HEX = "0123456789ABCDEF";
const norm = (h: string) => h.replace("#", "").toUpperCase().padEnd(6, "0").slice(0, 6);
const light = (h: string) => {
const n = parseInt(norm(h), 16);
const c = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => ((v /= 255) <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2] > 0.3;
};
function HexRoll({ hex }: { hex: string }) {
return (
<span className="tfan-hex" aria-hidden>
<span className="tfan-hash">#</span>
{norm(hex)
.split("")
.map((ch, i) => (
<span key={i} className="tfan-col">
<span
className="tfan-strip"
style={{
transform: `translateY(${-HEX.indexOf(ch)}em)`,
transitionDelay: `${i * 28}ms`,
}}
>
{HEX.split("").map((d) => (
<span key={d}>{d}</span>
))}
</span>
</span>
))}
</span>
);
}
export function SwatchFan({ swatches = DEFAULT_SWATCHES, value, defaultValue, onChange, labels, onSound, spread: spreadIn = 10.5, peek: peekIn = 3, bounce = 40, className, style }: SwatchFanProps) {
const gaps = Math.max(1, swatches.length - 1);
const spread = Math.min(spreadIn, 73.5 / gaps); // 7 gaps of 10.5 deg is the footprint the scene is drawn for
const peek = Math.min(peekIn, 21 / gaps);
const L = { ...DEFAULT_SWATCHFAN_LABELS, ...labels };
const lastAt = useRef<Record<string, number>>({});
const cue = (name: SwatchFanCue, gap = 0, at?: number) => {
const now = performance.now();
if (gap && now - (lastAt.current[name] ?? -Infinity) < gap) return;
lastAt.current[name] = now;
onSound?.(name, at === undefined ? undefined : { at });
};
const id = useId();
const n = swatches.length;
const idxOf = (h?: string) => (h ? swatches.findIndex((s) => norm(s.hex) === norm(h)) : -1);
const [inner, setInner] = useState(() => Math.max(0, idxOf(defaultValue) >= 0 ? idxOf(defaultValue) : Math.min(2, n - 1)));
const picked = value !== undefined && idxOf(value) >= 0 ? idxOf(value) : inner;
const [open, setOpen] = useState(false);
const [hot, setHot] = useState(-1);
const root = useRef<HTMLDivElement>(null);
const cards = useRef<(HTMLDivElement | null)[]>([]);
const knobs = useRef({ spread, peek, bounce, picked, n });
knobs.current = { spread, peek, bounce, picked, n };
const restB0 = (p: number, sp: number) => REST - p * sp;
const s = useRef({
b0: restB0(picked, peek),
sp: peek,
vb: 0,
vs: 0,
tb: restB0(picked, peek),
ts: peek,
out: [] as number[],
vo: [] as number[],
open: false,
hot: -1,
kbd: false,
step: 0,
raf: 0,
g: null as null | {
id: number;
x0: number;
y0: number;
moved: boolean;
h: number;
th0: number;
phi0: number;
b0: number;
vs: number;
lt: number;
lsp: number;
},
});
const paint = () => {
const c = s.current;
cards.current.forEach((el, i) => {
if (!el) return;
el.style.transform = `rotate(${(-(c.b0 + i * c.sp)).toFixed(3)}deg) translateX(${(c.out[i] ?? 0).toFixed(2)}px)`;
});
// names fade in as the cards part far enough to show them
root.current?.style.setProperty("--tfan-open", clamp((c.sp - 5.5) / 2.5, 0, 1).toFixed(3));
const st = Math.floor((c.sp * (n - 1)) / 7);
if (c.g?.moved && st !== c.step) cue("detent", 34, c.sp / knobs.current.spread);
c.step = st;
};
const kick = () => {
const c = s.current;
if (c.raf) return;
let last = 0;
const f = (t: number) => {
const dt = last ? clamp((t - last) / 16.67, 0, 2.5) : 1;
last = t;
const still = prefersReducedMotion();
const { bounce: bn } = knobs.current;
const d = (0.8 - (clamp(bn, 0, 100) / 100) * 0.12) ** dt;
let busy = !!c.g;
if (still) ((c.b0 = c.tb), (c.sp = c.ts), (c.vb = c.vs = 0));
else if (!c.g?.moved) {
c.vb += (c.tb - c.b0) * 0.16 * dt;
c.vs += (c.ts - c.sp) * 0.16 * dt;
c.vb *= d;
c.vs *= d;
c.b0 += c.vb * dt;
c.sp += c.vs * dt;
}
if (Math.abs(c.tb - c.b0) + Math.abs(c.ts - c.sp) > 0.01 || Math.abs(c.vb) + Math.abs(c.vs) > 0.01) busy = true;
else if (!c.g) {
c.b0 = c.tb;
c.sp = c.ts;
}
for (let i = 0; i < knobs.current.n; i++) {
const to = c.open && c.hot === i ? OUT : 0;
c.out[i] ??= to;
c.vo[i] ??= 0;
if (still) ((c.out[i] = to), (c.vo[i] = 0));
else {
c.vo[i] += (to - c.out[i]) * 0.2 * dt;
c.vo[i] *= 0.7 ** dt;
c.out[i] += c.vo[i] * dt;
}
if (Math.abs(to - c.out[i]) > 0.05 || Math.abs(c.vo[i]) > 0.05) busy = true;
}
paint();
c.raf = busy ? requestAnimationFrame(f) : 0;
};
c.raf = requestAnimationFrame(f);
};
useLayoutEffect(() => {
const c = s.current;
c.out = swatches.map(() => 0);
c.vo = swatches.map(() => 0);
paint();
return () => {
cancelAnimationFrame(c.raf);
c.raf = 0;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// a new pick (or a controlled value) rests the deck around it
useEffect(() => {
const c = s.current;
if (c.open || c.g) return;
c.tb = restB0(picked, knobs.current.peek);
c.ts = knobs.current.peek;
kick();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [picked, peek]);
const setOpenBoth = (o: boolean) => {
s.current.open = o;
setOpen(o);
};
const setHotBoth = (h: number) => {
s.current.hot = h;
setHot(h);
kick();
};
const fanOpen = (sp = spread * 0.85) => {
const c = s.current;
c.tb = OPEN0;
c.ts = sp;
setOpenBoth(true);
kick();
};
const close = (p: number) => {
const c = s.current;
c.tb = restB0(p, peek);
c.ts = peek;
setOpenBoth(false);
setHotBoth(-1);
kick();
};
const pick = (i: number) => {
cue("choose", 0, i / Math.max(1, n - 1));
if (value === undefined) setInner(i);
onChange?.(swatches[i]);
close(i);
};
const phiOf = (e: RPE) => {
const r = root.current!.getBoundingClientRect();
const k = r.width / root.current!.offsetWidth || 1;
return (Math.atan2(PIN.y - (e.clientY - r.top) / k, (e.clientX - r.left) / k - PIN.x) * 180) / Math.PI;
};
const down = (e: RPE<HTMLDivElement>) => {
const c = s.current;
if (c.g || e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
const hit = (e.target as HTMLElement).closest<HTMLElement>("[data-card]");
const hi = hit ? Number(hit.dataset.card) : -1;
c.g = {
id: e.pointerId,
x0: e.clientX,
y0: e.clientY,
moved: false,
h: hi,
th0: 0,
phi0: 0,
b0: 0,
vs: 0,
lt: e.timeStamp,
lsp: c.sp,
};
};
const move = (e: RPE) => {
const c = s.current;
if (e.movementX || e.movementY) c.kbd = false;
const g = c.g;
if (!g || g.id !== e.pointerId) return;
if (!g.moved) {
if (Math.hypot(e.clientX - g.x0, e.clientY - g.y0) < 4) return;
g.moved = true;
// the card under the finger is the handle; the bottom card holds still and the rest share the angle
g.h = g.h >= 1 ? g.h : n - 1;
g.b0 = c.b0;
g.th0 = c.b0 + g.h * c.sp;
g.phi0 = phiOf(e);
c.vb = c.vs = 0;
cue("grab");
setHotBoth(-1);
if (!c.open) setOpenBoth(true);
}
const max = knobs.current.spread;
const raw = (g.th0 + phiOf(e) - g.phi0 - g.b0) / g.h;
// the fan gives like rubber past shut and past fully open
const sp = raw < 0 ? 1.5 * Math.tanh(raw / 1.5) : raw > max ? max + 2 * Math.tanh((raw - max) / 2) : raw;
const dtm = Math.max(1, e.timeStamp - g.lt);
g.vs = g.vs * 0.6 + ((sp - c.sp) / dtm) * 16.67 * 0.4;
g.lt = e.timeStamp;
c.b0 = g.b0;
c.sp = sp;
c.tb = g.b0;
c.ts = sp;
kick();
};
const up = (e: RPE, cancel: boolean) => {
const c = s.current;
const g = c.g;
if (!g || g.id !== e.pointerId) return;
c.g = null;
const { picked: p, spread: max } = knobs.current;
if (!g.moved) {
if (cancel) return;
if (c.open && g.h >= 0) return pick(g.h);
if (c.open) return (cue("fold"), close(p));
cue("unfold");
return fanOpen();
}
// a flick keeps going: project the spread speed, then either stay open or fold back around the pick
const fling = e.timeStamp - g.lt > 90 ? 0 : clamp(g.vs, -3, 3) * 5;
const end = c.sp + fling;
c.vs = clamp(g.vs, -3, 3);
cue("place");
if (cancel || end < 4) close(p);
else ((c.tb = OPEN0), (c.ts = clamp(end, 5, max)), kick());
};
const key = (e: RKE) => {
const c = s.current;
const k = e.key;
c.kbd = true; // a resting mouse must not steal the highlight while the fan moves under it
const from = c.hot >= 0 ? c.hot : picked;
const to = k === "ArrowUp" || k === "ArrowRight" ? from + 1 : k === "ArrowDown" || k === "ArrowLeft" ? from - 1 : k === "Home" ? 0 : k === "End" ? n - 1 : -9;
if (to !== -9) {
e.preventDefault();
const i = clamp(to, 0, n - 1);
const was = c.open; // opening the fan is the one sound; stepping through an open fan ticks
if (!was) (cue("unfold"), fanOpen());
if (i !== c.hot) (setHotBoth(i), was && cue("pip", 30, i / Math.max(1, n - 1)));
return;
}
if (k === "Enter" || k === " ") {
e.preventDefault();
if (c.open && c.hot >= 0) pick(c.hot);
else (cue("unfold"), fanOpen(), setHotBoth(picked));
} else if (k === "Escape" && c.open) {
e.preventDefault();
cue("fold");
close(picked);
}
};
// at rest, a hovering mouse makes the deck part a little more: an invitation to pull it open
const invite = (on: boolean) => {
const c = s.current;
if (c.open || c.g) return;
const sp = on ? peek * 1.7 : peek;
c.tb = restB0(picked, sp);
c.ts = sp;
kick();
};
if (!n)
return (
<div className={cn("tfan-root", className)} style={style}>
<span className="tfan-none">{L.empty}</span>
</div>
);
const show = open && hot >= 0 ? hot : picked;
const sw = swatches[show];
return (
<div
ref={root}
className={cn("tfan-root", className)}
style={style}
data-open={open || undefined}
role="listbox"
tabIndex={0}
aria-label={L.deck}
aria-activedescendant={open && hot >= 0 ? `${id}-${hot}` : `${id}-${picked}`}
onPointerDown={down}
onPointerMove={move}
onPointerUp={(e) => up(e, false)}
onPointerCancel={(e) => up(e, true)}
onLostPointerCapture={(e) => up(e, true)}
onKeyDown={key}
onBlur={() => s.current.open && !s.current.g && close(picked)}
onPointerEnter={(e) => e.pointerType === "mouse" && invite(true)}
onPointerLeave={(e) => e.pointerType === "mouse" && invite(false)}
>
<div className="tfan-read" data-preview={show !== picked || undefined}>
<span className="tfan-chip" style={{ background: sw.hex }} />
<span className="tfan-txt">
<HexRoll hex={sw.hex} />
<small>{sw.name}</small>
</span>
</div>
<span className="tfan-sr" aria-live="polite">
{L.swatch(swatches[picked].name, norm(swatches[picked].hex))}
</span>
{swatches.map((w, i) => (
<div
key={w.hex + i}
id={`${id}-${i}`}
data-card={i}
role="option"
aria-selected={i === picked}
aria-label={L.swatch(w.name, norm(w.hex))}
ref={(el) => {
cards.current[i] = el;
}}
className="tfan-card"
data-picked={i === picked || undefined}
data-hot={open && hot === i ? "" : undefined}
data-ink={light(w.hex) ? "dark" : "light"}
style={{
left: PIN.x - 16,
top: PIN.y - 16,
zIndex: n + 1 - Math.abs(i - show),
background: w.hex,
}}
onPointerEnter={(e) => e.pointerType === "mouse" && !s.current.g && !s.current.kbd && s.current.open && hot !== i && setHotBoth(i)}
onPointerLeave={(e) => e.pointerType === "mouse" && !s.current.g && !s.current.kbd && s.current.hot === i && setHotBoth(-1)}
>
<span className="tfan-name">
<b>{w.name}</b>
</span>
</div>
))}
<span className="tfan-rivet" style={{ left: PIN.x, top: PIN.y }} aria-hidden />
</div>
);
}
/* SwatchFan. Prefix tfan-. */
.tfan-root,
.tfan-root * {
box-sizing: border-box;
}
.tfan-root {
--tfan-ease: cubic-bezier(0.22, 1, 0.36, 1);
--tfan-ink: var(--ts-fg, #14161b);
--tfan-slab: var(--ts-raised, #fff);
font-family: var(--ts-font, system-ui, sans-serif);
-webkit-font-smoothing: antialiased;
--tfan-open: 0;
position: relative;
width: 260px;
height: 240px;
flex: none;
color: var(--tfan-ink);
font-size: 12px;
letter-spacing: -0.01em;
outline: 0;
cursor: grab;
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
}
.tfan-root:active {
cursor: grabbing;
}
.tfan-root:focus-visible {
border-radius: var(--ts-r-panel);
box-shadow:
0 0 0 2px var(--tfan-slab),
0 0 0 4px var(--ts-accent, #3446eb);
}
/* open, the ring moves to the card you are on */
.tfan-root[data-open]:focus-visible {
box-shadow: none;
}
/* cards: paint strips riveted at one end */
.tfan-card {
position: absolute;
width: 176px;
height: 32px;
border-radius: 7px;
transform-origin: 16px 16px;
box-shadow:
inset 0 0 0 1px rgba(0, 0, 0, 0.1),
0 0 0 1px color-mix(in srgb, var(--tfan-slab) 72%, transparent);
}
.tfan-name {
position: absolute;
right: 9px;
top: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
line-height: 1;
opacity: var(--tfan-open);
pointer-events: none;
transition: opacity 0.16s var(--tfan-ease);
}
.tfan-name b {
max-width: 140px;
overflow: clip;
overflow-clip-margin: 3px;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 12px;
font-weight: 600;
}
.tfan-card[data-ink="dark"] .tfan-name {
color: rgba(0, 0, 0, 0.72);
}
.tfan-card[data-ink="light"] .tfan-name {
color: rgba(255, 255, 255, 0.92);
}
.tfan-root:not([data-open]) .tfan-card[data-picked] .tfan-name,
.tfan-card[data-hot] .tfan-name {
opacity: 1;
}
.tfan-root:focus-visible .tfan-card[data-hot] {
box-shadow:
inset 0 0 0 1px rgba(0, 0, 0, 0.1),
0 0 0 2px var(--tfan-slab),
0 0 0 4px var(--ts-accent, #3446eb);
}
.tfan-rivet {
position: absolute;
z-index: 40;
width: 8px;
height: 8px;
translate: -50% -50%;
border-radius: 50%;
background: var(--tfan-slab);
box-shadow: 0 0 0 1.5px var(--tfan-ink);
pointer-events: none;
}
.tfan-none {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--ts-fg-muted, #6a6f7b);
}
/* readout */
.tfan-read {
position: absolute;
left: 12px;
top: 206px;
z-index: 30;
display: flex;
align-items: center;
gap: 10px;
pointer-events: none;
}
.tfan-chip {
width: 34px;
height: 34px;
border-radius: 10px;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1);
transition: background-color 0.42s var(--tfan-ease);
}
.tfan-txt {
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.tfan-txt small {
max-width: 150px;
overflow: clip;
overflow-clip-margin: 3px;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 12px;
line-height: 1;
color: var(--ts-fg-muted, #6a6f7b);
transition: color 0.16s var(--tfan-ease);
}
.tfan-read[data-preview] small {
color: var(--ts-fg-faint, #858a96);
}
.tfan-hex {
display: inline-flex;
height: 1em;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 13px;
font-weight: 500;
line-height: 1em;
letter-spacing: 0;
font-variant-numeric: tabular-nums;
}
.tfan-hash {
color: var(--ts-fg-faint, #858a96);
}
.tfan-col {
display: inline-block;
height: 1em;
overflow: hidden;
}
.tfan-strip {
display: flex;
flex-direction: column;
transition: transform 0.42s var(--tfan-ease);
}
.tfan-strip span {
height: 1em;
}
.tfan-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.tfan-strip,
.tfan-chip {
transition: none;
}
}
/*
* Teseria tokens — the only colours, fonts and corner radii a block reads.
*
* Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
* the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
* any ancestor, or the OS setting unless [data-theme="light"] is set.
*
* Surfaces page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
* Text fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
* Lines border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
* dark; composes with other shadows)
* Signals accent · focus · success · warning · danger
* Corners controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
* (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
*/
:root {
--ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ts-r-panel: 24px;
--ts-r-pop: 18px;
--ts-r-inner: 12px;
--ts-page: #ffffff;
--ts-raised: #ffffff;
--ts-sunken: #f3f2f0;
--ts-tint-1: #f5f4f2;
--ts-tint-2: #ecebe8;
--ts-tint-3: #e3e1dd;
--ts-fg: #161514;
--ts-fg-rgb: 22, 21, 20;
--ts-fg-secondary: #3a3836;
--ts-fg-tertiary: #585552;
--ts-fg-muted: #6d6a66;
--ts-fg-faint: #89857f;
--ts-fg-inverse: #ffffff;
--ts-border: rgba(22, 21, 20, 0.1);
--ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);
--ts-accent: #3446eb;
--ts-focus: #3446eb;
--ts-success: #13804a;
--ts-warning: #a9650b;
--ts-danger: #df474e;
}
:root[data-theme="dark"] {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
}
3. Usage
import { SwatchFan } from "./SwatchFan";
import "./SwatchFan.css";
export default function Demo() {
return <SwatchFan />;
}