Select
Styles a native <select> control: removes the default appearance, applies themed colors, outline, density-scaled padding/radius, a custom chevron background icon, and hover/focus/disabled/optgroup/option states.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
color | ThemeColor | "neutral" | Theme color tone for text, background, and outline. |
accentColor | ThemeColor | "primary" | Theme color tone for hover and focus outlines. |
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 { Listener, PartialElement } from "@domphy/core";
import {
type ThemeColor,
themeColor,
themeColorToken,
themeDensity,
themeSize,
themeSpacing,
} from "@domphy/theme";
import { isRTL } from "../utils/direction.js";
import { focusRing } from "../utils/focusRing.js";
/**
* Styles a native `<select>` control: removes the default appearance, applies themed
* colors, outline, density-scaled padding/radius, a custom chevron background icon, and
* hover/focus/disabled/optgroup/option states.
*
* @hostTag select
* @param props.color - Theme color tone for text, background and outline. Defaults to `"neutral"`.
* @param props.accentColor - Theme color tone for hover/focus outlines. Defaults to `"primary"`.
* @example { select: [{ option: "A" }], $: [select()] }
*/
function select(
props: { color?: ThemeColor; accentColor?: ThemeColor } = {},
): PartialElement {
const { color = "neutral", accentColor = "primary" } = props;
return {
_onInsert: (node) => {
if (node.tagName !== "select") {
console.warn(`"select" primitive patch must use select tag`);
}
},
style: {
appearance: "none",
fontSize: (listener) => themeSize(listener, "inherit"),
lineHeight: "inherit",
color: (listener) => themeColor(listener, "text", color),
backgroundColor: (listener) => themeColor(listener, "inherit", color),
border: "none",
outlineOffset: "-1px",
outline: (listener) =>
`1px solid ${themeColor(listener, "border-strong", color)}`,
borderRadius: (listener) => themeSpacing(themeDensity(listener) * 1.5),
paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 1),
paddingInlineStart: (listener) =>
themeSpacing(themeDensity(listener) * 3),
paddingInlineEnd: (listener) => themeSpacing(themeDensity(listener) * 5),
backgroundImage: (l: Listener) => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 6"><path d="M1 1l4 4 4-4" stroke="${themeColorToken(l, "shift-7")}" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
},
backgroundRepeat: "no-repeat",
// background-position has no logical ("inline-end") keyword support in
// Chrome 88-class embedded browsers, so the physical side is picked at
// paint time from the resolved CSS `direction` (same technique
// @domphy/floating's platform.isRTL uses) instead of a fixed "right".
backgroundPosition: (l: Listener) =>
`${isRTL(l.elementNode?.domElement) ? "left" : "right"} ${themeSpacing(2)} center`,
backgroundSize: `${themeSpacing(2.5)} ${themeSpacing(1.5)}`,
transition: "outline-color 140ms ease, box-shadow 140ms ease",
// No height floor: a form control is sized by its line box plus its own
// paddingBlock, the shape inputText/inputNumber/inputSearch and every
// button use, so a select standing in a toolbar row is exactly as tall as
// its neighbours. A floor here made it 1U taller than all of them.
"&:hover:not([disabled]):not([aria-busy=true])": {
outline: (listener) =>
`1px solid ${themeColor(listener, "shift-5", accentColor)}`,
},
"&:focus-visible": {
boxShadow: (listener) => focusRing(listener, accentColor),
},
"& optgroup": {
color: (listener) => themeColor(listener, "shift-11", color),
},
"& option[disabled]": {
color: (listener) => themeColor(listener, "shift-7", "neutral"),
},
"&[disabled]": {
opacity: 0.7,
cursor: "not-allowed",
color: (listener) => themeColor(listener, "muted", "neutral"),
outline: (listener) =>
`1px solid ${themeColor(listener, "border-strong", "neutral")}`,
backgroundColor: (listener) =>
themeColor(listener, "shift-2", "neutral"),
},
},
};
}
export { select };