Tag
Apply the tag patch to a <span> to style it as a pill-shaped inline chip with a colored border and background. Set color to choose the theme tone and removable: true to insert an × button that removes the chip from the DOM on click.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
color | ValueOrState<ThemeColor> | "neutral" | Theme color for the chip background, border, and text. |
removable | boolean | false | When true, renders a remove (×) button that removes the tag from the DOM on click. |
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
dataTone,dataSize, anddataDensityon a container to shift tone, size, or density 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.
Formulas
Unit - U = fontSize / 4 - convert final values with themeSpacing(n).
Size - n = intrinsic text lines, w = wrapping level, d = density factor:
height = (n * 6 + 2 * d * w) * U
paddingBlock = d * w * U
paddingInline = ceil(3 / w) * d * w * U
radius = d * w * UBase density d = 1.5:
| U | w=0 | w=1 | w=2 | w=3 |
|---|---|---|---|---|
height (n = 1) | 6 | 9 | 12 | 15 |
| paddingBlock | 0 | 1.5 | 3 | 4.5 |
| paddingInline | 3 | 4.5 | 6 | 4.5 |
| radius | 0 | 1.5 | 3 | 4.5 |
Tone - K = N / 2 where N is the palette length. For N = 18, K = 9.
| Role | Shift | n=0 |
|---|---|---|
| Background | parent +/- n | 0 |
| Text | bg + K | 6 |
| Border | bg + K/2 | 3 |
| Hover | bg + 2K/3 | 4 |
| Selected / Focus | above +/- K/3 | 2-4 |
State shift range: K/3 <= delta <= 2K/3.
import {
behavior,
type DomphyElement,
ElementNode,
type PartialElement,
rawHtml,
toState,
type ValueOrState,
} from "@domphy/core";
import {
type ThemeColor,
themeColor,
themeDensity,
themeSize,
themeSpacing,
} from "@domphy/theme";
import { focusRing } from "../utils/focusRing.js";
const xSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M6.707 5.293l5.293 5.292l5.293 -5.292a1 1 0 0 1 1.414 1.414l-5.292 5.293l5.292 5.293a1 1 0 0 1 -1.414 1.414l-5.293 -5.292l-5.293 5.292a1 1 0 1 1 -1.414 -1.414l5.292 -5.293l-5.292 -5.293a1 1 0 0 1 1.414 -1.414" /></svg>`;
type TagRemoveProps = { removable: boolean };
/**
* Styles an inline chip/tag (rounded, bordered, optional remove button).
* No host tag check; typically applied to a `<span>`. When `removable` is true,
* a close button is inserted that removes the host node on click or Enter/Space.
*
* @hostTag span
* @param props.color - Theme color for the chip background/border/text. Optional, accepts a value or state. Defaults to `"neutral"`.
* @param props.removable - When true, renders a remove (x) button that removes the tag on click or Enter/Space. Optional. Defaults to `false`.
* @example { span: "Label", $: [tag({ removable: true })] }
*/
function tag(
props: { color?: ValueOrState<ThemeColor>; removable?: boolean } = {},
): PartialElement {
const { removable = false } = props;
const color = toState(props.color ?? "neutral", "color");
return {
dataTone: "shift-2",
// _onInit would miss/orphan the button when a reused node is re-patched
// with a flipped `removable`. behavior() attaches once and routes later
// generations through update().
...behavior<TagRemoveProps>(
"tag-remove",
(node, initial) => {
const removeHost = () => node.remove();
const makeButton = (): DomphyElement<"span"> => ({
span: rawHtml(xSvg),
_key: "tag-remove",
onClick: (e) => {
(e as Event).stopPropagation();
removeHost();
},
onKeyDown: (e) => {
const event = e as KeyboardEvent;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
event.stopPropagation();
removeHost();
},
tabindex: 0,
role: "button",
ariaLabel: "Remove",
style: {
display: "inline-flex",
alignItems: "center",
cursor: "pointer",
borderRadius: (listener) =>
themeSpacing(themeDensity(listener) * 1),
width: (listener) => themeSpacing(themeDensity(listener) * 4),
height: (listener) => themeSpacing(themeDensity(listener) * 4),
flexShrink: 0,
transition: "background-color 140ms ease, box-shadow 140ms ease",
"&:hover": {
backgroundColor: (listener) =>
themeColor(listener, "shift-4", color.get(listener)),
},
"&:focus-visible": {
boxShadow: (listener) => focusRing(listener, color.get(listener)),
},
},
});
const findButton = () =>
node.children.items.find(
(item) => item instanceof ElementNode && item.key === "tag-remove",
) ?? null;
// attach() runs from the host Mount hook, which fires BEFORE
// render() walks children. Inserting with updateDom=true would
// create a DOM node that render() then creates a second time.
// First paint: list-only insert, let render() materialize it.
// Later update(): the walk already finished, so mutate the live DOM.
const sync = (next: TagRemoveProps, updateDom: boolean) => {
const existing = findButton();
if (next.removable && !existing) {
node.children.insert(makeButton(), undefined, updateDom);
} else if (!next.removable && existing) {
node.children.remove(existing);
}
};
sync(initial, false);
return {
update: (next) => sync(next, true),
};
},
{ removable },
),
style: {
display: "inline-flex",
alignItems: "center",
whiteSpace: "nowrap",
userSelect: "none",
height: (listener) => themeSpacing(themeDensity(listener) * 6),
paddingBlock: 0,
borderRadius: (listener) => themeSpacing(themeDensity(listener) * 999),
paddingInlineStart: (listener) =>
themeSpacing(themeDensity(listener) * 2.5),
paddingInlineEnd: (listener) =>
themeSpacing(themeDensity(listener) * (removable ? 1 : 2.5)),
gap: themeSpacing(1.5),
fontSize: (listener) => themeSize(listener, "decrease-1"),
backgroundColor: (listener) =>
themeColor(listener, "inherit", color.get(listener)),
color: (listener) => themeColor(listener, "text", color.get(listener)),
border: "none",
outlineOffset: "-1px",
outline: (listener) =>
`1px solid ${themeColor(listener, "border-strong", color.get(listener))}`,
},
};
}
export { tag };