블록
테마 색 견본
테마의 색 견본과 토큰 표를 라이트·다크 나란히 보여 줘요.
- #arc
- #block
- #theme
- #tokens
- #color
- #swatch
- #table
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/theme-swatches.json파일
components/arc/blocks/theme-swatches/theme-swatches.module.csscomponents/arc/blocks/theme-swatches/theme-swatches.tsx
함께 오는 항목
theme-swatches.module.csstheme-swatches.module.csstheme-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.tsxtheme-swatches.tsxtheme-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;