Skip to content
Block

Theme swatches

A theme's swatches and token table, light and dark side by side.

  • #arc
  • #block
  • #theme
  • #tokens
  • #color
  • #swatch
  • #table

Install

One command installs the files and packages this item needs.

Terminalbash
npx shadcn@latest add https://dsui.vercel.app/r/theme-swatches.json

theme-swatches.module.csscss
.columns { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: var(--space-4); }
/* The column paints itself from its own tokens, so the dark one reads as dark on a light page. */
.column { display: flex; flex-direction: column; gap: var(--space-4); border: 1px solid var(--column-border); border-radius: var(--radius-panel); padding: var(--space-5); background: var(--column-surface); color: var(--column-foreground); }

.swatches { display: grid; grid-template-columns: repeat(auto-fit, minmax(72px, 1fr)); gap: var(--space-3); }
.swatchCell { display: flex; min-width: 0; flex-direction: column; gap: var(--space-2); }
/* A border of its own, so a chip the colour of the surface still has an edge. */
.swatch { display: block; height: var(--space-12); border: 1px solid var(--column-border); border-radius: var(--radius-control); }
.swatchName { overflow: hidden; color: color-mix(in oklab, var(--column-foreground) 62%, transparent); font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; font-size: var(--text-xs); text-overflow: ellipsis; white-space: nowrap; }

.table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); text-align: start; }
.table th, .table td { border-block-end: 1px solid color-mix(in oklab, var(--column-border) 70%, transparent); padding: var(--space-2) 0; text-align: start; vertical-align: top; }
.table thead th { color: color-mix(in oklab, var(--column-foreground) 62%, transparent); font-size: var(--text-xs); font-weight: var(--label-weight); }
.table tbody th { font-weight: 400; }
.table code { font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; font-size: var(--text-xs); }
.table tbody th code { color: var(--column-foreground); }
.usage { display: block; margin-block-start: var(--space-1); color: color-mix(in oklab, var(--column-foreground) 62%, transparent); font-size: var(--text-xs); line-height: 1.5; word-break: keep-all; }

.value { display: inline-flex; align-items: center; gap: var(--space-2); border: 0; border-radius: var(--radius-pill); padding: var(--space-1) var(--space-2); background: transparent; color: color-mix(in oklab, var(--column-foreground) 70%, transparent); font: inherit; cursor: pointer; transition: color var(--duration-fast) var(--ease-standard), background var(--duration-fast) var(--ease-standard); }
.copyIcon { flex: none; opacity: 0; transition: opacity var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }
.copyIcon[data-copied] { opacity: 1; color: var(--success); }
@media (hover: hover) and (pointer: fine) {
  .value:hover { background: color-mix(in oklab, var(--column-foreground) 8%, transparent); color: var(--column-foreground); }
  .value:hover .copyIcon { opacity: 1; }
}
/* Without hover the icon has to be there all along, or nothing says the value can be copied. */
@media (hover: none) { .copyIcon { opacity: .6; } }
.value:focus-visible .copyIcon { opacity: 1; }

.label { margin: auto 0 0; color: color-mix(in oklab, var(--column-foreground) 62%, transparent); font-size: var(--text-xs); font-weight: var(--label-weight); text-align: center; }
.srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) { .value, .copyIcon { transition: none; } }
/* Press feedback: the same answer the hover gives, so touch and mouse both respond. */
.value:active { background: color-mix(in oklab, var(--column-foreground) 14%, transparent); color: var(--column-foreground); }
theme-swatches.tsxtsx
"use client";

import type { CSSProperties } from "react";
import { useCopyFeedback } from "../../lib/use-copy-feedback";
import { playUiSound } from "../../sound/play";
import { UiIcon } from "../../theme/ui-icon";
import styles from "./theme-swatches.module.css";

export interface ThemeToken {
  /** The CSS variable, without the leading dashes: "accent". */
  name: string;
  /** The value in this theme. Any CSS colour, or a length for a radius or spacing token. */
  value: string;
  /** One line on where it is used. */
  usage?: string;
  /** Draws a colour chip. Off for lengths and other non-colour values. */
  swatch?: boolean;
}

export interface ThemeColumn {
  /** The mode this column shows, used for `data-theme` so the chips paint in that theme. */
  mode: "light" | "dark";
  label: string;
  tokens: ThemeToken[];
}

export interface ThemeSwatchesProps {
  columns: ThemeColumn[];
  /** The accessible name of the table, e.g. "halloween 테마의 토큰". */
  label?: string;
  headers?: { token: string; value: string };
  className?: string;
}

/**
 * A theme's colours beside the names they have in code. The two modes sit side by side rather than behind a toggle,
 * because the question a reader has here is "what does this look like in the other one" — and flipping a switch to
 * compare means holding one of them in your head.
 *
 * A column paints itself from its own `surface`, `foreground` and `border` tokens where it has them, so the dark
 * column reads as dark even on a light page. It cannot do this by setting `data-theme`: a theme's values hang off
 * `:root`, so the attribute means nothing on an element inside the page.
 */
export function ThemeSwatches({ columns, label, headers = { token: "토큰", value: "값" }, className }: ThemeSwatchesProps) {
  const { state, activeKey, copy } = useCopyFeedback();
  const valueOf = (column: ThemeColumn, names: string[]) => column.tokens.find(t => names.includes(t.name))?.value;
  return (
    <div className={[styles.columns, className].filter(Boolean).join(" ")}>
      {columns.map(column => (
        <section
          key={column.mode}
          className={styles.column}
          aria-label={`${column.label} ${label ?? ""}`.trim()}
          style={{
            "--column-surface": valueOf(column, ["surface", "background"]) ?? "var(--surface)",
            "--column-foreground": valueOf(column, ["foreground", "ink"]) ?? "var(--foreground)",
            "--column-border": valueOf(column, ["border"]) ?? "var(--border)",
          } as CSSProperties}
        >
          <div className={styles.swatches}>
            {column.tokens.filter(t => t.swatch !== false).map(token => (
              <div key={token.name} className={styles.swatchCell}>
                <span className={styles.swatch} style={{ background: token.value }} aria-hidden="true" />
                <span className={styles.swatchName}>{token.name}</span>
              </div>
            ))}
          </div>

          <table className={styles.table}>
            <thead>
              <tr><th scope="col">{headers.token}</th><th scope="col">{headers.value}</th></tr>
            </thead>
            <tbody>
              {column.tokens.map(token => {
                const key = `${column.mode}:${token.name}`;
                const copied = state === "copied" && activeKey === key;
                return (
                  <tr key={token.name}>
                    <th scope="row">
                      <code>{`--${token.name}`}</code>
                      {token.usage ? <span className={styles.usage}>{token.usage}</span> : null}
                    </th>
                    <td>
                      <button
                        type="button"
                        className={styles.value}
                        aria-label={`${token.value} 복사하기`}
                        onClick={() => { playUiSound(copied ? "success" : "click"); void copy(token.value, key); }}
                      >
                        <code>{token.value}</code>
                        <UiIcon iconRole={copied ? "check" : "copy"} size={14} strokeWidth={1.75} aria-hidden="true" className={styles.copyIcon} data-copied={copied ? "" : undefined} />
                      </button>
                    </td>
                  </tr>
                );
              })}
            </tbody>
          </table>
          <p className={styles.label}>{column.label}</p>
          <span role="status" className={styles.srOnly}>{state === "copied" ? "값을 복사했어요" : ""}</span>
        </section>
      ))}
    </div>
  );
}

ThemeSwatches.displayName = "ThemeSwatches";

export default ThemeSwatches;