Teseria
All elements

Swatch fan

Colour picker as a paint fan deck.

Code

1. Install

Terminal
# No packages beyond React.

2. Files

SwatchFan.tsx440 lines
"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.css190 lines
/* 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;
  }
}
tokens.cssshared · add once
/*
 * 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

Demo.tsx
import { SwatchFan } from "./SwatchFan";
import "./SwatchFan.css";

export default function Demo() {
  return <SwatchFan />;
}