Color Roles & Semantic Colors
Semantic vs. literal colors
Never use literal colors ("#3b82f6", "rgba(0,0,0,0.5)") in Domphy elements. The doctor flags these as raw-theme-value. Instead, use semantic color roles via themeColor():
import { themeColor } from "@domphy/theme"
// ✗ Literal — breaks dark mode, doctor flags it
const bad = { div: "Error", style: { color: "#ef4444" } }
// ✓ Semantic — adapts to theme and dark mode
const good = { div: "Error", style: { color: (l) => themeColor(l, "shift-9", "error") } }
// ✓ Even better — use a patch
const best = { span: "Error", $: [small({ color: "error" })] }Surface roles
Use shift scale anchors for backgrounds and borders:
| Role | Tone | Use |
|---|---|---|
| Page background | "inherit" | Root background |
| Card background | "shift-1" | Slightly elevated surface |
| Input background | "shift-2" | Form fields, code blocks |
| Border | "shift-3" | Dividers, input borders |
| Muted border | "shift-4" | Subtle separators |
| Placeholder text | "shift-6" | Disabled / hint text |
| Secondary text | "shift-8" | Labels, captions |
| Body text | "shift-10" | Primary readable content |
| Heading text | "shift-12" | High-contrast headings |
| Icon | "shift-9" | Action icons |
const Card = {
div: CardContent,
style: {
background: (l) => themeColor(l, "shift-1"),
border: (l) => `1px solid ${themeColor(l, "shift-3")}`,
borderRadius: (l) => themeSpacing(2),
padding: (l) => themeSpacing(4),
},
}Semantic color families
Use color families for meaning, not decoration:
| Family | When to use |
|---|---|
"primary" | Main actions, links, active states |
"secondary" | Secondary actions, alternative paths |
"success" | Completed, confirmed, positive |
"warning" | Caution, degraded state |
"error" / "danger" | Failed, destructive, critical |
"info" | Informational, neutral notices |
"attention" / "highlight" | Callouts, promotions |
"neutral" | Default, disabled, placeholder |
const StatusBadge = (status: "success" | "warning" | "error") => ({
span: status,
style: {
background: (l) => themeColor(l, "shift-2", status),
color: (l) => themeColor(l, "shift-11", status),
border: (l) => `1px solid ${themeColor(l, "shift-5", status)}`,
padding: (l) => `${themeSpacing(0.5)} ${themeSpacing(2)}`,
borderRadius: (l) => themeSpacing(1),
fontSize: (l) => themeSize(l, "decrease-1"),
},
})Interactive state colors
Use "increase-N" tone for hover/pressed states:
import { toState } from "@domphy/core"
function ActionButton({ label, onClick }: { label: string; onClick: () => void }) {
const hovered = toState(false)
return {
button: label,
onClick,
onMouseEnter: () => hovered.set(true),
onMouseLeave: () => hovered.set(false),
style: {
background: (l) => themeColor(l,
hovered.get(l) ? "increase-1" : "shift-2",
"primary"
),
color: (l) => themeColor(l, "shift-11", "primary"),
border: (l) => `1px solid ${themeColor(l, "shift-6", "primary")}`,
padding: (l) => `${themeSpacing(themeDensity(l) * 1)} ${themeSpacing(themeDensity(l) * 3)}`,
cursor: "pointer",
transition: "background 100ms ease",
},
}
}Focus ring
Standard accessible focus ring using the primary color:
const FocusableInput = {
input: null,
type: "text",
style: {
outline: "none",
boxShadow: (l) => `0 0 0 2px ${themeColor(l, "shift-7", "primary")}`,
},
// Only show ring when focused
// Use the CSS :focus-visible selector via a class or :focus-within on parent
}Overlay colors with opacity
For overlays, shadows, and scrims:
const Overlay = {
div: null,
style: {
position: "fixed",
inset: 0,
// Use neutral at high shift with opacity for scrim
background: (l) => themeColor(l, "shift-15", "neutral"),
opacity: 0.5,
zIndex: 100,
},
}themeColorToken — concrete hex values
When a third-party library requires a raw hex/rgb (e.g., Chart.js, D3, canvas):
import { themeColorToken } from "@domphy/theme"
// Returns a concrete string like "#4a7ff4" resolved from the theme
const chartColor = themeColorToken(myElement, "shift-9", "primary")
// Use in Chart.js config
const chartConfig = {
data: {
datasets: [{
backgroundColor: themeColorToken(myElement, "shift-3", "primary"),
borderColor: themeColorToken(myElement, "shift-9", "primary"),
}],
},
}themeColorToken uses CIELAB/LCH matching — the same algorithm that powers @domphy/doctor's raw-theme-value hint.
Color audit
Run the doctor to find literal colors:
import { diagnose } from "@domphy/doctor"
const issues = diagnose(MyApp)
const colorIssues = issues.filter(i => i.rule === "raw-theme-value")
// Each issue includes a hint with the nearest themeColor() equivalent:
// { rule: "raw-theme-value", message: "Use themeColor(l, 'shift-9', 'error') instead of '#ef4444'" }