Creation

Create a new patch only after you understand core, theme, and the customization path.

The goal is to keep the patch API stable, predictable, and easy to scan in app code.

Rules

  • A patch is a function returning a PartialElement.
  • A patch is not a component and does not own a tag-content pair.
  • Keep one patch per file.
  • Do not import other patches or local helper files into a patch file.
  • Pass state through props or context. Do not hide state inside patch internals.
  • Only include attributes that are really required, especially accessibility attributes.
  • Declare DOM events flat on the partial object, not inside hooks.
  • Only create sub-patches when parent and child really need shared context or behavior.
  • The fastest way to start is copying a similar patch and editing it.

Props

  • Use one object parameter with a default of {}.
  • Keep the prop surface minimal.
  • Do not export the props type.
  • Add a prop only when the value recurs often enough that inline override becomes awkward.
  • color and accentColor are the common prop patterns across many patches.

Styles

Apply styles in this order of preference:

  1. Patch
  2. Theme helper
  3. Fixed raw CSS

Use an existing patch first. If no patch fits, use themeSpacing(), themeSize(), and themeColor(). Only fall back to raw fixed values when the system cannot express the value.

Overlay elements should use tone shift-11.

Not To Do

  • Do not put normal DOM event logic inside hooks; use flat event keys like onClick, onInput, and onKeyDown directly on the partial object instead. The main exception is _onBeforeRemove, where a DOM listener such as transitionend may be necessary because done() only exists there.
  • Do not use _key as selected state, active id, or general business identity; keep _key only for child diffing during reactive updates.
  • Do not reuse the same DomphyElement or PartialElement object across multiple inserts; create a fresh object each time with a factory function or inside the loop.
  • Do not hide simple visual state changes inside hooks; if the change is really an attribute or style update, use reactive attributes or reactive style props directly.
  • Do not skip done() in _onBeforeRemove; if done() is not called, the node will never finish removal.
  • Do not create local helper functions inside patch files; keep the file as one patch factory so the structure stays predictable across the library.

Tag-Name Contract

When a patch owns a complex layout with multiple named regions, and each region can be identified by a unique child tag, prefer CSS child selectors keyed by tag name instead of inventing sub-patches.

div $[card()]
├── img      -> image area
├── h2 / h3  -> title
├── p        -> description
├── aside    -> extra slot
├── div      -> content
└── footer   -> actions

Use this pattern when:

  • the layout has many distinct slots
  • each slot already has a natural semantic tag
  • sub-patches would only add naming overhead

If two different slots need the same tag, fall back to sub-patches.

Reference Example

<div class="blocks">
<div class="block active" data-tab="0">
import { type PartialElement, toState, type ValueOrState } from "@domphy/core";
import {
  type ThemeColor,
  themeColor,
  themeDensity,
  themeSize,
  themeSpacing,
} from "@domphy/theme";

/**
 * A themed button control with density-aware padding/radius and hover, focus-visible,
 * `[disabled]`, and `[aria-busy=true]` states. Apply to a `<button>` element.
 *
 * @hostTag button
 * @param props.color - Button color tone. Optional `ValueOrState<ThemeColor>`, default "primary".
 * @example { button: "Save", $: [button({ color: "primary" })] }
 */
function button(
  props: { color?: ValueOrState<ThemeColor> } = {},
): PartialElement {
  const color = toState(props.color ?? "primary", "color");

  return {
    _onInsert: (node) => {
      if (node.tagName !== "button") {
        console.warn(`"button" primitive patch must use button tag`);
      }
    },
    style: {
      appearance: "none",
      fontSize: (listener) => themeSize(listener, "inherit"),
      // Single-line bounded control: block/radius = 1D, inline = 3D.
      paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 1),
      paddingInline: (listener) => themeSpacing(themeDensity(listener) * 3),
      borderRadius: (listener) => themeSpacing(themeDensity(listener) * 1),
      width: "fit-content",
      display: "flex",
      justifyContent: "center",
      alignItems: "center",
      gap: (listener) => themeSpacing(themeDensity(listener) * 1),
      userSelect: "none",
      fontFamily: "inherit",
      lineHeight: "inherit",
      border: "none",
      outlineOffset: "-1px",
      outlineWidth: "1px",
      outline: (listener) =>
        `1px solid ${themeColor(listener, "shift-4", color.get(listener))}`,
      color: (listener) => themeColor(listener, "shift-9", color.get(listener)),
      backgroundColor: (listener) =>
        themeColor(listener, "inherit", color.get(listener)),
      "&:hover:not([disabled]):not([aria-busy=true])": {
        color: (listener) =>
          themeColor(listener, "shift-10", color.get(listener)),
        backgroundColor: (listener) =>
          themeColor(listener, "shift-2", color.get(listener)),
      },
      "&:focus-visible": {
        boxShadow: (listener) =>
          `inset 0 0 0 ${themeSpacing(0.5)} ${themeColor(listener, "shift-6", color.get(listener))}`,
      },
      "&[disabled]": {
        opacity: 0.7,
        cursor: "not-allowed",
        backgroundColor: (listener) =>
          themeColor(listener, "shift-2", "neutral"),
        outline: (listener) =>
          `1px solid ${themeColor(listener, "shift-4", "neutral")}`,
        color: (listener) => themeColor(listener, "shift-8", "neutral"),
      },
      "&[aria-busy=true]": {
        opacity: 0.7,
        cursor: "wait",
        pointerEvents: "none",
      },
    },
  };
}

export { button };
</div>
</div>

When Not To Create A Patch

Some patterns should stay outside @domphy/ui.

PatternWhy no patchAlternative
AccordionNative <details> / <summary> already provide the behavior.details patch
Progress CircleNeeds fixed pixel geometry that does not fit the typography-based dimension rules.SVG directly
Drag and DropHit-testing, ghost elements, and sorting logic are beyond patch scope.SortableJS integration
Form ValidationValidation belongs to the data layer, not the UI patch layer.Zod integration
Data Fetching / Async StateLoading and caching belong to data tools.TanStack Query integration
RoutingRouting is application-level, not element-level.page.js integration
i18nLocale and translation state belong to a dedicated i18n system.i18next integration