references/components.md
Component conventions
Read after SKILL.md. These are the patterns every component in the system follows - copying them is what keeps thirty components feeling like one.
The variant-map pattern
Variants are a Record keyed by a union type, not a chain of conditionals. The type and the map are declared together, so adding a variant without styling it is a compile error.
import { ButtonHTMLAttributes, forwardRef, ReactNode } from "react";
import { cn } from "@/lib/cn";
type Variant = "primary" | "secondary" | "ghost" | "danger";
type Size = "sm" | "md";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
/** Optional leading icon (lucide-react component instance). */
icon?: ReactNode;
}
const VARIANTS: Record<Variant, string> = {
primary: "bg-brand text-white hover:bg-brand-hover focus-visible:ring-brand-ring",
secondary: "bg-surface-2 text-ink-primary border border-divider hover:border-divider-2 hover:bg-surface-3",
ghost: "bg-transparent text-ink-secondary hover:bg-surface-2 hover:text-ink-primary",
danger: "bg-transparent text-danger hover:bg-danger-soft",
};
const SIZES: Record<Size, string> = {
sm: "h-7 px-2.5 text-[12px] gap-1.5",
md: "h-8 px-3 text-[13px] gap-2",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ variant = "secondary", size = "md", icon, className, type = "button", children, ...rest },
ref
) {
return (
<button
ref={ref}
type={type}
className={cn(
"inline-flex items-center justify-center font-medium rounded-md press-feedback",
"transition-colors focus:outline-none",
"disabled:opacity-40 disabled:cursor-not-allowed",
VARIANTS[variant],
SIZES[size],
className
)}
{...rest}
>
{icon && <span className="shrink-0">{icon}</span>}
{children}
</button>
);
});
Five things in there are load-bearing:
secondaryis the default, notprimary. Orange is punctuation. If every
button defaulted to primary you would end up with six oranges on a screen.
classNamecomes last incn(), so a caller can always override.- The rest props are spread, so a consumer gets
onClick,aria-*,
data-* without the component enumerating them.
forwardRefon anything focusable or measurable - popovers, palettes, and
focus management all need the node.
type="button"by default. The HTML default issubmit, which silently
submits a surrounding form. This has bitten every codebase that forgot it.
cn()
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
clsx handles conditionals; twMerge resolves Tailwind conflicts so a later class actually wins. Without twMerge, cn("px-3", "px-6") emits both and the result depends on stylesheet order - the bug where an override "randomly" works on one component and not another.
Badge
Status pills are mono, 10px, uppercase, tracked, with a soft-tinted background from the accent tokens. The -soft variables exist exactly for this.
const VARIANTS: Record<Variant, string> = {
default: "bg-surface-3 text-ink-secondary",
success: "bg-success-soft text-success",
danger: "bg-danger-soft text-danger",
warning: "bg-warning-soft text-warning",
info: "bg-info-soft text-info",
brand: "bg-brand-soft text-brand",
};
// inline-flex items-center px-1.5 h-[18px] rounded-sm whitespace-nowrap
// font-mono text-[10px] uppercase tracking-wider font-medium
An 18px fixed height matters: badges live inside 26-36px rows, and letting them size to content makes rows jitter between values.
Layer inventory
What a mature version of this system contains, as a target to build toward:
atoms - Avatar, Badge, Button, IconButton, Input, Kbd, LogoTile, StatusDot
molecules - Breadcrumbs, EmptyState, ErrorBanner, FilterChip, PageHeader, ResourceTable, SidebarItem, StatCard, TableLink, plus domain pickers (UserPicker, AdminPicker) and editors (KeyValueRowsEditor, SpecListEditor)
organisms - CommandPalette, ShortcutOverlay, Sidebar, StatusBar, Toast, TopBar
templates - AdminShell
Note the shape: few atoms, many molecules. That ratio is healthy. A system with thirty atoms has usually mislabelled its molecules, and a system with three molecules is about to grow copy-pasted table markup on every page.
Utilities worth having
From globals.css, referenced by components:
.press-feedback-scale(0.98)on:active. Every interactive element..scrollbar-thin- themed scrollbars; the default light scrollbar on a dark
panel is jarring.
.shadow-topbar- the one shadow in the UI.- A global
*:focus-visibleorange ring, explicitly disabled on
input/textarea/select, because the Input atom already changes its wrapper border on focus-within and two rings read as a rendering bug.
- A
prefers-reduced-motionblock that flattens all durations.