Input Checkbox
Styles a custom checkbox with a themed box, check mark, indeterminate state, hover, focus, and disabled styling. Apply to an <input type="checkbox"> element — the patch sets type: "checkbox".
Disabled keeps the control's shape and only drops the colour family to neutral (Radix and MUI do the same): the box keeps its outline, so a disabled unchecked checkbox still reads as empty. The previous rule filled the box with flat grey and removed the outline, which made a disabled unchecked box look more "on" than an enabled one and indistinguishable from a disabled checked one.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
color | ValueOrState<ThemeColor> | "neutral" | Theme color tone for the box border and resting background. |
accentColor | ValueOrState<ThemeColor> | "primary" | Theme color tone for the checked/indeterminate fill and focus ring. |
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 { type PartialElement, toState, type ValueOrState } from "@domphy/core";
import {
type ThemeColor,
themeColor,
themeSize,
themeSpacing,
} from "@domphy/theme";
import { focusRing } from "../utils/focusRing.js";
/**
* Styles a custom checkbox with themed box, check mark, indeterminate state,
* hover, focus and disabled styling. Apply to an `<input>` element of
* type `checkbox` (the patch sets `type: "checkbox"`).
*
* @hostTag input
* @param props.color - Optional theme color tone for the box/border (`ValueOrState<ThemeColor>`). Defaults to `"neutral"`.
* @param props.accentColor - Optional theme color tone for the checked/indeterminate fill and focus ring (`ValueOrState<ThemeColor>`). Defaults to `"primary"`.
* @example { input: null, type: "checkbox", $: [inputCheckbox()] }
*/
function inputCheckbox(
props: {
color?: ValueOrState<ThemeColor>;
accentColor?: ValueOrState<ThemeColor>;
} = {},
): PartialElement {
const color = toState(props.color ?? "neutral", "color");
const accentColor = toState(props.accentColor ?? "primary", "accentColor");
return {
type: "checkbox",
_onInsert: (node) => {
if (node.tagName !== "input") {
console.warn(`"inputCheckbox" primitive patch must use input tag`);
}
},
style: {
appearance: "none",
fontSize: (listener) => themeSize(listener, "inherit"),
color: (listener) => themeColor(listener, "text", color.get(listener)),
display: "inline-flex",
position: "relative",
width: themeSpacing(6),
height: themeSpacing(6),
justifyContent: "center",
alignItems: "center",
transition: "background-color 300ms, outline-color 300ms",
margin: 0,
padding: 0,
"&::before": {
content: `""`,
display: "block",
borderRadius: themeSpacing(1),
lineHeight: 1,
cursor: "pointer",
border: "none",
outlineOffset: "-1px",
outline: (listener) =>
`1px solid ${themeColor(listener, "border-strong", color.get(listener))}`,
color: (listener) => themeColor(listener, "text", color.get(listener)),
width: themeSpacing(4),
height: themeSpacing(4),
},
"&:hover::before": {
backgroundColor: (listener) =>
themeColor(listener, "hover", color.get(listener)),
},
"&:checked::before": {
outline: (listener) =>
`1px solid ${themeColor(listener, "shift-6", accentColor.get(listener))}`,
backgroundColor: (listener) =>
themeColor(listener, "shift-8", accentColor.get(listener)),
},
"&:checked:hover:not([disabled])::before": {
backgroundColor: (listener) =>
themeColor(listener, "shift-7", accentColor.get(listener)),
},
"&:checked::after": {
content: `""`,
display: "block",
position: "absolute",
top: "25%",
insetInlineStart: "37%",
width: "20%",
height: "30%",
border: (listener) =>
`${themeSpacing(0.5)} solid ${themeColor(listener, "inherit", accentColor.get(listener))}`,
borderTop: 0,
borderInlineStart: 0,
transform: "rotate(45deg)",
},
"&:indeterminate::before": {
outline: (listener) =>
`1px solid ${themeColor(listener, "shift-6", accentColor.get(listener))}`,
backgroundColor: (listener) =>
themeColor(listener, "shift-3", accentColor.get(listener)),
},
"&:indeterminate::after": {
content: `""`,
position: "absolute",
inset: "30%",
backgroundColor: (listener) =>
themeColor(listener, "shift-8", accentColor.get(listener)),
},
"&:indeterminate:hover:not([disabled])::after": {
backgroundColor: (listener) =>
themeColor(listener, "shift-7", accentColor.get(listener)),
},
"&:focus-visible": {
borderRadius: themeSpacing(1.5),
boxShadow: (listener) => focusRing(listener, accentColor.get(listener)),
},
"&[disabled]": {
cursor: "not-allowed",
opacity: 0.7,
},
// Disabled keeps the control's SHAPE and only drops the colour family to
// neutral (Radix / MUI do the same). The previous rule painted ::before
// with a flat grey fill and removed its outline, so in Chromium a
// disabled UNCHECKED box read as filled — more "on" than the enabled
// unchecked box, and indistinguishable from the disabled checked one.
"&[disabled]::before": {
pointerEvents: "none",
outline: (listener) =>
`1px solid ${themeColor(listener, "border-strong", "neutral")}`,
backgroundColor: (listener) =>
themeColor(listener, "inherit", "neutral"),
},
"&[disabled]:checked::before": {
backgroundColor: (listener) =>
themeColor(listener, "shift-8", "neutral"),
},
"&[disabled]:indeterminate::before": {
backgroundColor: (listener) =>
themeColor(listener, "shift-3", "neutral"),
},
"&[disabled]:checked::after": {
pointerEvents: "none",
// Light end of the neutral ramp on the shift-8 fill, mirroring the
// enabled check: `inherit` resolved to a mid grey that all but
// disappeared against it.
borderColor: (listener) => themeColor(listener, "shift-0", "neutral"),
},
"&[disabled]:indeterminate::after": {
pointerEvents: "none",
backgroundColor: (listener) =>
themeColor(listener, "shift-8", "neutral"),
},
},
};
}
export { inputCheckbox };