Appearance
UI
The @domphy/ui package is the official collection of Patches. Each patch is a PartialElement object from @domphy/core that applies styles, attributes, or behaviors to any element. A patch does not render a new element — it transforms an existing one by merging into it. Patches are designed with minimal props and context awareness, reducing the effort needed to read documentation while ensuring simple, flexible customization.
typescript
import { button, tooltip } from "@domphy/ui"
const submitButton = {
button: "Submit",
$: [
button({ color: "primary" }),
tooltip({ content: "Submit the form" }),
]
}All patches in @domphy/ui automatically apply the context-aware Tone and Size systems from @domphy/theme. No manual color tokens or spacing values are required.
The patch documentation is structured as a flat catalog. The sections below serve as usage-oriented references rather than strict source layers.
Patches Dimensions
All dimension values are spacing units (U). Base unit: U = fontSize / 4. Formula: Height = NLines × 6 + 2 × WrappingLevel.
The table below covers the main size families. Multi-part or behavior-first patches such as Card, Command, Input OTP, Splitter, Toggle, and toggleGroup() are documented on their own pages.
| Height | Patches |
|---|---|
| 1px | (Separators) Horizontal Rule |
| 2U | Progress, Popover Arrow |
| 4U | Input Checkbox, Input Radio, Input Range, Input Switch |
| 6U | Code, Keyboard, Mark, Abbreviation, Badge, Breadcrumb Ellipsis, Emphasis, Heading, Icon, Label, Link, Small, Spinner, Strong, Subscript, Superscript, Tag, Skeleton, Toggle |
| 6n U | Breadcrumb, Ordered List, Paragraph, Unordered List, Description List |
| 8U | Avatar, Button, Button Switch, Combobox, Divider, Input Color, Input Date Time, Input File, Input Number, Input Search, Input Text, Menu Item, Pagination, Select, Select Box, Select Item, Select List, Tab, Tooltip |
| 8n U | Table |
| (6n+4) U | Alert, Blockquote, Details, Figure, Image, Popover, Preformated, Tabs, Textarea, Toast |
| — | (Layout Regions) Dialog, Drawer, Form Group, Menu, Tab Panel |
| — | (Behavior) TransitionGroup |
The formula applies to elements at or above the 6U baseline — the height of one body text line, which is the most visually dominant unit in any layout. Elements intentionally sized below this baseline follow a proportional sub-scale of 2U / 4U / 6U (ratio 1:2:3). Input Checkbox, Input Radio, and Input Switch have a 4U visual indicator inside a 6U hit area and auto-align correctly. Progress (2U) and Input Range (4U) are full-element sub-baseline sizes and require manual vertical alignment when placed inline.
Customization
Must see the source of patch at the bottom of each patch page to understand the structure then code it still code as html native element.
There are four levels of customization, in increasing order of effort:
- Patch props. Each patch exposes a small, stable set of props—typically fewer than five. Lowest friction.
- Context attributes. Use
dataToneanddataSizeon a container to shift tone or scale for an entire subtree without touching individual elements. - Inline override. Native-wins merge strategy: any property set directly on the element overrides the patch value.
- Create a variant. Clone a similar patch and edit it. Use this only when you need a reusable custom version.
Creation
This guide aims to ensure API stability, minimize breaking changes, and provide a strong developer experience. Before creating a new patch, understand Core and the design system, then try customizing existing patches first. Only create a new patch when genuine reuse across multiple projects is necessary.
Rules:
- A patch is a function returning a
PartialElement— aDomphyElement-shaped object without atag:contentpair. - Each patch must be independent. Do not import other patches or local files.
- Keep one patch per file. Do not bundle multiple patches into a single module.
- Pass state via props or read it from context. Never mix state into patch internals.
- Only include strictly required attributes, such as accessibility attributes (
role,aria-*). - Declare events (
onClick,onKeyDown, etc.) flat in the partial object — not inside hooks. Nesting event listeners inside hooks creates deep, hard-to-read callback chains and makes it difficult to see at a glance what a patch responds to. The only valid exception is writing a DOM event listener (e.g.transitionend) inside_onBeforeRemove, becausedone()— which must be called to complete the removal — is only available in that hook's scope. - Only create sub-patches (e.g.
commandSearch,commandItem,tab,tabPanel,cardHeader,cardBody) when the component genuinely requires shared context between parent and child (e.g.Tabscoordinating active state). - The fastest way to start is by copying and modifying an existing patch.
Props:
- Props must be a single object parameter (
{}) with minimal keys to preserve stability and call-site readability. Do not export the props type. - Only add a prop when a value recurs frequently enough that inline overriding becomes impractical.
color(defaults toneutral) andaccentColor(defaults toprimaryfor indicators, focus states, etc.) are the standard props used across most patches.
Styles:
Apply styles in priority order — always prefer the highest level that applies:
- Patch — if a patch already covers the visual need, use it. Never write raw CSS when a patch exists. Example: use
small()instead offontSize: "0.875em", useparagraph()instead oflineHeight: "1.5". - Theme — if no patch applies, use theme functions:
themeSpacing(),themeSize(),themeColor(). These values are context-aware and scale with the design system. - Fixed — only when neither a patch nor a theme function can express the value (e.g.,
border: "none",display: "flex",1pxseparators).
- Strictly adhere to
@domphy/themerules. - Overlay elements should use tone
shift-11(invert).
Tag-name contract (template pattern)
When a patch defines a complex layout with multiple named regions — and the regions can be identified by their HTML tag — use CSS child selectors keyed by tag name instead of creating sub-patches. Each direct child tag maps to exactly one layout slot. The patch owns the entire layout; the user only places semantic elements.
div $[card()]
├── img → image area (full width)
├── h2 / h3 → title area (col 1)
├── p → description (col 1)
├── aside → extra slot (col 2, spans title + desc)
├── div → content (full width)
└── footer → actions (full width, flex row)This is implemented with grid-template-areas hardcoded inside the patch. Unused slots collapse to zero height automatically — no placeholder elements needed. The contract is: one tag = one slot. Looking at the tag tells you its position; no sub-patch names to remember.
Use this pattern when:
- The layout has 4 or more distinct regions that cannot be naturally nested.
- All slots are identifiable by a unique HTML tag among direct children.
- Sub-patches would only add naming overhead without providing shared state or behavior.
This pattern scales to more complex templates (e.g. a dashboard tile with header, aside, section, figure, footer) as long as each slot uses a distinct tag. When two slots need the same tag, fall back to sub-patches.
Example button patch
ts
import { type PartialElement } from "@domphy/core";
import { themeSpacing, themeColor, themeSize, type ThemeColor, } from "@domphy/theme";
function button(props: { color?: ThemeColor } = {}): PartialElement {
const { color = "primary" } = props;
return {
_onInsert: (node) => {
if (node.tagName != "button") {
console.warn(`"button" primitive patch must use button tag`);
}
},
style: {
appearance: "none",
fontSize: (listener) => themeSize(listener, "inherit"),
paddingBlock: themeSpacing(1),
paddingInline: themeSpacing(3),
borderRadius: themeSpacing(2),
width: "fit-content",
display: "flex",
justifyContent: "center",
alignItems: "center",
userSelect: "none",
fontFamily: "inherit",
lineHeight: "inherit",
border: "none",
outlineOffset: "-1px",
outlineWidth: "1px",
outline: (listener) => `1px solid ${themeColor(listener, "shift-3", color)}`,
color: (listener) => themeColor(listener, "shift-6", color),
backgroundColor: (listener) => themeColor(listener, "inherit", color),
"&:hover:not([disabled]):not([aria-busy=true])": {
color: (listener) => themeColor(listener, "shift-7", color),
backgroundColor: (listener) => themeColor(listener, "shift-1", color),
},
"&:focus-visible": {
boxShadow: (listener) => `inset 0 0 0 ${themeSpacing(0.5)} ${themeColor(listener, "shift-6", color)}`,
},
"&[disabled]": {
opacity: 0.7,
cursor: "not-allowed",
backgroundColor: (listener) => themeColor(listener, "shift-1", "neutral"),
outline: (listener) => `1px solid ${themeColor(listener, "shift-3", "neutral")}`,
color: (listener) => themeColor(listener, "shift-5", "neutral"),
},
"&[aria-busy=true]": {
opacity: 0.7,
cursor: "wait",
pointerEvents: "none",
},
},
};
}
export { button };Excludes
These patterns are intentionally not shipped as patches. Either the browser already provides the behavior natively, the design constraint cannot be satisfied within @domphy/theme rules, or an external library handles it better than a patch ever could.
| Pattern | Why no patch | Alternative |
|---|---|---|
| Accordion | <details> / <summary> provide open/close natively with no JS — a patch would only add visual style, not behavior. | details patch |
| Progress Circle | Requires a fixed pixel dimension (SVG r or border-radius: 50%), which conflicts with the typography-based size formula in @domphy/theme. No correct size can be computed at theme level. | SVG directly with stroke-dashoffset animation |
| Drag and Drop | Sorting, reordering, and cross-list drag require hit-testing, ghost elements, and scroll handling — far beyond what a patch can own. | SortableJS integration |
| Form Validation | Schema validation, error mapping, and async rules belong to the data layer, not the UI layer. A patch cannot own validation logic without violating the data/UI boundary. | Zod integration |
| Data Fetching / Async State | Loading, caching, refetch, and error state are data concerns. Wrapping them in a patch blurs ownership and increases abstraction for no gain. | TanStack Query integration |
| Routing | Page transitions and URL-to-view mapping are application-level concerns, not element-level. | page.js integration |
| i18n | Translation lookup, locale switching, and pluralization belong to a dedicated i18n library. A patch cannot own locale state without leaking data concerns into UI. | i18next integration |