Select
A native select restyled with the customizable select API.
The .select restyles a native <select> using appearance: base-select, so the dropdown
picker matches the rest of the system where supported. Falls back to the native control
otherwise (gated behind @supports).
The base-select pattern requires a <button><selectedcontent></selectedcontent></button> as
the select's first child — the button is the trigger and <selectedcontent> mirrors the chosen
option. Keep the <option>s (an <optgroup> label works as a native fallback) after it.
Default
<div class="field"> <label class="field__label" for="f-sort">Sort by</label> <select class="select" id="f-sort" name="sort"> <button> <selectedcontent></selectedcontent> </button> <!-- Keep label in optgroup as a fallback --> <optgroup class="grid gap-px" label="Sort by"> <legend class="text-eyebrow text-muted-foreground p-xs">Sort by</legend> <option value="recent" selected>Most recent</option> <option value="popular">Most popular</option> <option value="price-asc">Price: low to high</option> <option value="price-desc">Price: high to low</option> </optgroup> </select></div>/** * _select.css — Select (.select on customizable <select>) * * @layer variables, reset, components * @requires layers.css, _variables.css, _fields.css, _reset.css, _popover.css (--popover-*), _button.css (--button-*) * @uses appearance: base-select — customizable select (Chromium 135+ / Safari 17+) * @uses ::picker(select), ::picker-icon, selectedcontent, option::checkmark * @uses @starting-style + allow-discrete — picker open animation * @uses anchor-size(width) — match picker width to trigger (@supports gated) * @uses position-area, position-try-fallbacks — data-side × data-align picker placement (@supports gated) * @uses backdrop-filter — picker surface blur (shared with [popover]) * @uses Degrades to native OS dropdown where base-select is unsupported * @tokens --select-* (@layer variables); picker panel reuses --popover-* (owned in _popover.css) */ @layer variables { :root { /* Box treatment — alias the shared --field-* family (owned in _fields.css) so a select, a text input, and a textarea read as one family. Retune a --field-* token and every control follows; remap a --select-* token to diverge just the select. */ --select-inline-size: 100%; --select-min-inline-size: 16ch; --select-padding-inline-start: var(--field-padding); /* Trailing space differs from .input — room for the picker icon */ --select-padding-inline-end: var(--step-2); --select-font-size: var(--field-font-size); --select-line-height: var(--field-line-height); --select-border: 1px solid var(--field-border); --select-border-radius: var(--field-radius); /* Picker icon (trigger chevron) ** @see https://www.svgbackgrounds.com/tools/svg-to-css/ ** Set to Legacy / URL Wrapper to update icon */ --select-picker-icon-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Crect width='256' height='256' fill='none'/%3E%3Cpolyline points='80 176 128 224 176 176' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'/%3E%3Cpolyline points='80 80 128 32 176 80' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'/%3E%3C/svg%3E"); --select-picker-icon-size: var(--field-icon-size); --select-picker-icon-color: currentColor; /* Option (menu item) — a ghost button rendered as a native <option>. Alias the --button-* family (owned in _button.css) so a select's options and a dropdown's ghost menu items read as one; retune a --button-* token and both follow. The ghost look is base button minus the surface fill + border, so only --option-background/--option-border diverge. Radius bridges to the shared --popover-button-radius like every other [popover]-surface button. */ --option-font-family: var(--button-font-family); --option-font-size: var(--button-font-size); --option-font-weight: var(--button-font-weight); --option-line-height: var(--button-line-height); --option-gap: var(--button-gap); --option-padding: var(--button-padding); --option-height: var(--button-height); --option-icon-size: var(--button-icon-size); --option-radius: var(--popover-button-radius); --option-shadow: none; --option-background: transparent; --option-background--hover: var(--button-background--hover); --option-background--active: var(--button-background--active); --option-foreground: var(--button-foreground); --option-foreground--hover: var(--button-foreground--hover); --option-foreground--active: var(--button-foreground--active); --option-border: 1px solid transparent; --option-border--hover: 1px solid transparent; --option-border--active: 1px solid transparent; /* Option checkmark ** @see https://www.svgbackgrounds.com/tools/svg-to-css/ ** Set to Legacy / URL Wrapper to update icon */ --option-checkmark-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Crect width='256' height='256' fill='none'/%3E%3Cpolyline points='40 144 96 200 224 72' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'/%3E%3C/svg%3E"); --option-checkmark-size: var(--field-icon-size); --option-checkmark-color: currentColor; /* Reveal + surface for the dropdown panel are shared with [popover] — the picker reads the --popover-* family (owned in _popover.css) so a select's menu and a popover read as one surface. Retune a --popover-* token and both follow; override here to diverge just the select's picker. */ }}@layer reset { /* =========================================================================== SELECT — native <select> internals (::picker, <option>, ::picker-icon) - In the reset layer so component overrides win; the plain appearance reset stays in _reset.css. - Both the select and ::picker(select) need appearance: base-select before picker pseudo styles apply (MDN customizable select). =========================================================================== */ :where(.select), :where(.select)::picker(select) { appearance: base-select; } :where(.select)::picker(select) { transform-origin: var(--popover-origin); opacity: var(--popover-opacity--start); backdrop-filter: var(--popover-backdrop-filter); transform: scale(var(--popover-scale--start)) translate3d(var(--popover-translate--start)); transition: opacity var(--default-transition-duration) var(--default-transition-timing-function), transform var(--default-transition-duration) var(--default-transition-timing-function), padding var(--default-transition-duration) var(--default-transition-timing-function), block-size var(--default-transition-duration) var(--default-transition-timing-function), inline-size var(--default-transition-duration) var(--default-transition-timing-function), overlay var(--default-transition-duration) var(--default-transition-timing-function) allow-discrete, display var(--default-transition-duration) var(--default-transition-timing-function) allow-discrete; } :where(.select:open)::picker(select) { opacity: var(--popover-opacity--end); transform: scale(var(--popover-scale--end)) translate3d(var(--popover-translate--end)); display: flex; } @starting-style { :where(.select:open)::picker(select) { opacity: var(--popover-opacity--start); transform: scale(var(--popover-scale--start)) translate3d(var(--popover-translate--start)); } } :where(option)::checkmark { content: ""; inline-size: var(--option-checkmark-size); block-size: var(--option-checkmark-size); background-color: var(--option-checkmark-color); mask: var(--option-checkmark-mask) center / contain no-repeat; } :where(.select)::picker-icon { content: ""; inline-size: var(--select-picker-icon-size); block-size: var(--select-picker-icon-size); background-color: var(--select-picker-icon-color); mask: var(--select-picker-icon-mask) center / contain no-repeat; }}@layer zazz.components { /* =========================================================================== SELECT — .select (customizable <select>, no JS) - appearance: base-select themes the trigger, picker, options, and arrow while the browser keeps keyboard nav, top-layer rendering, and form integration. - Needs a <button><selectedcontent></selectedcontent></button> child. Degrades to the native OS dropdown where unsupported (the _reset.css chevron covers it). - Shares the --field-* tokens defined in _fields.css. =========================================================================== */ .select { display: flex; align-items: center; justify-content: space-between; gap: var(--field-gap); inline-size: var(--select-inline-size); min-inline-size: var(--select-min-inline-size); block-size: var(--field-height); padding-inline: var(--select-padding-inline-start) var(--select-padding-inline-end); font-size: var(--select-font-size); line-height: var(--select-line-height); text-align: start; color: var(--field-foreground); background-color: var(--field-background); background-image: none; border: var(--select-border); border-radius: var(--select-border-radius); cursor: pointer; /* ring renders as box-shadow; transparent outline twin keeps focus visible in forced-colors / high-contrast modes */ --_ring-offset-width: 0px; --_ring-width: 0px; box-shadow: 0 0 0 var(--_ring-offset-width) var(--ring-offset-color), 0 0 0 calc(var(--_ring-offset-width) + var(--_ring-width)) var(--ring); outline: var(--outline-width) var(--outline-style) transparent; outline-offset: var(--outline-offset); transition: var(--default-transition); } .select:hover { border-color: var(--field-border--hover); } .select:focus-visible, .select:open { border-color: var(--field-border--focus); --_ring-offset-width: var(--ring-offset-width); --_ring-width: var(--ring-width); outline-color: transparent; } /* Trigger button child holds the (optional) leading icon + mirrored value */ .select>button { block-size: 100%; display: flex; flex: 1; align-items: center; gap: var(--field-gap); min-inline-size: 0; overflow: hidden; } .select selectedcontent { display: flex; align-items: center; block-size: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* Trigger icons (leading glyph + mirrored value) size to the field icon */ .select>button>svg, .select selectedcontent>svg { flex-shrink: 0; inline-size: var(--field-icon-size); block-size: var(--field-icon-size); color: var(--muted-foreground); } .select:open { background-color: var(--field-background--focus); } .select:open::picker-icon { transform: rotate(-180deg); } /* Dropdown panel — the shared [popover] surface (--popover-* family), anchored to the trigger. anchor-size() matches the panel to the trigger width; it is anchor-related, so it is gated. The fallback (max-content) only matters in the narrow case of a browser that supports customizable <select> (::picker) but not anchor-size — there the panel sizes to its content instead of stretching. */ .select::picker(select) { flex-direction: column; gap: var(--step-px); inline-size: max-content; max-inline-size: var(--popover-max-inline-size); padding: var(--popover-padding); margin-block: var(--popover-margin-block); background-color: var(--popover); color: var(--popover-foreground); border: 1px solid var(--popover-border); border-radius: var(--popover-radius); box-shadow: var(--popover-shadow); } @supports (inline-size: anchor-size(width)) { .select::picker(select) { min-inline-size: anchor-size(width); } } /* =========================================================================== PICKER PLACEMENT — data-side × data-align, mirroring [popover] (row/block first, column/inline second per MDN). Set on the .select; drives the --popover-* tokens the surface + @supports block below consume. Defaults to bottom / start (no attributes) so the picker keeps dropping below the trigger. =========================================================================== */ /* bottom (default side) */ .select:not([data-side]):not([data-align])::picker(select), .select:not([data-side])[data-align="start"]::picker(select), .select[data-side="bottom"]:not([data-align])::picker(select), .select[data-side="bottom"][data-align="start"]::picker(select) { --popover-position-area: block-end span-inline-end; --popover-origin: top center; --popover-margin-block: var(--step-1_5); --popover-margin-inline: 0; } .select:not([data-side])[data-align="center"]::picker(select), .select[data-side="bottom"][data-align="center"]::picker(select) { --popover-position-area: block-end span-all; --popover-origin: top center; --popover-margin-block: var(--step-1_5); --popover-margin-inline: 0; } .select:not([data-side])[data-align="end"]::picker(select), .select[data-side="bottom"][data-align="end"]::picker(select) { --popover-position-area: block-end span-inline-start; --popover-origin: top center; --popover-margin-block: var(--step-1_5); --popover-margin-inline: 0; } /* top */ .select[data-side="top"]:not([data-align])::picker(select), .select[data-side="top"][data-align="start"]::picker(select) { --popover-position-area: block-start span-inline-end; --popover-origin: bottom center; --popover-margin-block: var(--step-1_5); --popover-margin-inline: 0; } .select[data-side="top"][data-align="center"]::picker(select) { --popover-position-area: block-start span-all; --popover-origin: bottom center; --popover-margin-block: var(--step-1_5); --popover-margin-inline: 0; } .select[data-side="top"][data-align="end"]::picker(select) { --popover-position-area: block-start span-inline-start; --popover-origin: bottom center; --popover-margin-block: var(--step-1_5); --popover-margin-inline: 0; } /* right */ .select[data-side="right"]:not([data-align])::picker(select), .select[data-side="right"][data-align="start"]::picker(select) { --popover-position-area: inline-end span-block-end; --popover-origin: bottom left; --popover-margin-block: 0; --popover-margin-inline: var(--step-1_5); } .select[data-side="right"][data-align="center"]::picker(select) { --popover-position-area: inline-end span-all; --popover-origin: center left; --popover-margin-block: 0; --popover-margin-inline: var(--step-1_5); } .select[data-side="right"][data-align="end"]::picker(select) { --popover-position-area: inline-end span-block-start; --popover-origin: top left; --popover-margin-block: 0; --popover-margin-inline: var(--step-1_5); } /* left */ .select[data-side="left"]:not([data-align])::picker(select), .select[data-side="left"][data-align="start"]::picker(select) { --popover-position-area: inline-start span-block-end; --popover-origin: bottom right; --popover-margin-block: 0; --popover-margin-inline: var(--step-1_5); } .select[data-side="left"][data-align="center"]::picker(select) { --popover-position-area: inline-start span-all; --popover-origin: center right; --popover-margin-block: 0; --popover-margin-inline: var(--step-1_5); } .select[data-side="left"][data-align="end"]::picker(select) { --popover-position-area: inline-start span-block-start; --popover-origin: top right; --popover-margin-block: 0; --popover-margin-inline: var(--step-1_5); } /* The picker is implicitly anchored to the select, so position-area needs no anchor-name — but it is still anchor positioning, gated like _popover.css so browsers without it (Firefox/Safari) keep the UA picker placement instead of a mispositioned box. */ @supports (anchor-name: --zazz) { .select::picker(select) { position-area: var(--popover-position-area); position-try-fallbacks: var(--popover-position-try-fallbacks); margin-inline: var(--popover-margin-inline); } } /* Options render as ghost buttons — the --option-* family (set in @layer variables) aliases the --button-* ghost values, so a select's menu items and a dropdown's ghost menu items read identically. Like .button, sizing rides on min-block-size + padding-inline (no block padding) so every row is uniform. */ .select option { display: flex; align-items: center; gap: var(--option-gap); min-block-size: var(--option-height); padding-inline: var(--option-padding); font-family: var(--option-font-family); font-size: var(--option-font-size); font-weight: var(--option-font-weight); line-height: var(--option-line-height); color: var(--option-foreground); background-color: var(--option-background); border: var(--option-border); border-radius: var(--option-radius); box-shadow: var(--option-shadow); cursor: pointer; transition: var(--default-transition); } .select option:hover { color: var(--option-foreground--hover); background-color: var(--option-background--hover); border: var(--option-border--hover); } .select option:checked { color: var(--option-foreground--active); background-color: var(--option-background--active); border: var(--option-border--active); } .select option:disabled { --option-foreground: var(--muted-foreground); cursor: default; opacity: 0.5; } .select option>svg { flex-shrink: 0; inline-size: var(--option-icon-size); block-size: var(--option-icon-size); color: var(--muted-foreground); } .select option::checkmark { order: 1; margin-inline-start: auto; color: currentcolor; }}Placement
Set data-side and data-align on the .select to place the picker. data-side picks which
edge of the trigger it opens against (defaults to bottom); data-align picks which trigger
edge the panel aligns to — and, when the options are wider than the trigger, which way it grows
(defaults to start). This mirrors the dropdown attributes, and is
gated behind @supports (anchor positioning), so browsers without it keep the native picker
placement. The picker also flips to the opposite side when there isn't room.
Sides
<div class="grid grid-cols-2 gap-sm place-items-center"> <div class="field"> <label class="field__label" for="f-side-top">Top</label> <select class="select" id="f-side-top" name="side-top" data-side="top"> <button> <selectedcontent></selectedcontent> </button> <optgroup class="grid gap-px" label="Sort by"> <legend class="text-eyebrow text-muted-foreground p-xs">Sort by</legend> <option value="recent" selected>Most recent</option> <option value="popular">Most popular</option> <option value="price-asc">Price: low to high</option> <option value="price-desc">Price: high to low</option> </optgroup> </select> </div> <div class="field"> <label class="field__label" for="f-side-bottom">Bottom</label> <select class="select" id="f-side-bottom" name="side-bottom" data-side="bottom"> <button> <selectedcontent></selectedcontent> </button> <optgroup class="grid gap-px" label="Sort by"> <legend class="text-eyebrow text-muted-foreground p-xs">Sort by</legend> <option value="recent" selected>Most recent</option> <option value="popular">Most popular</option> <option value="price-asc">Price: low to high</option> <option value="price-desc">Price: high to low</option> </optgroup> </select> </div> <div class="field"> <label class="field__label" for="f-side-left">Left</label> <select class="select" id="f-side-left" name="side-left" data-side="left"> <button> <selectedcontent></selectedcontent> </button> <optgroup class="grid gap-px" label="Sort by"> <legend class="text-eyebrow text-muted-foreground p-xs">Sort by</legend> <option value="recent" selected>Most recent</option> <option value="popular">Most popular</option> <option value="price-asc">Price: low to high</option> <option value="price-desc">Price: high to low</option> </optgroup> </select> </div> <div class="field"> <label class="field__label" for="f-side-right">Right</label> <select class="select" id="f-side-right" name="side-right" data-side="right"> <button> <selectedcontent></selectedcontent> </button> <optgroup class="grid gap-px" label="Sort by"> <legend class="text-eyebrow text-muted-foreground p-xs">Sort by</legend> <option value="recent" selected>Most recent</option> <option value="popular">Most popular</option> <option value="price-asc">Price: low to high</option> <option value="price-desc">Price: high to low</option> </optgroup> </select> </div></div>/** * _select.css — Select (.select on customizable <select>) * * @layer variables, reset, components * @requires layers.css, _variables.css, _fields.css, _reset.css, _popover.css (--popover-*), _button.css (--button-*) * @uses appearance: base-select — customizable select (Chromium 135+ / Safari 17+) * @uses ::picker(select), ::picker-icon, selectedcontent, option::checkmark * @uses @starting-style + allow-discrete — picker open animation * @uses anchor-size(width) — match picker width to trigger (@supports gated) * @uses position-area, position-try-fallbacks — data-side × data-align picker placement (@supports gated) * @uses backdrop-filter — picker surface blur (shared with [popover]) * @uses Degrades to native OS dropdown where base-select is unsupported * @tokens --select-* (@layer variables); picker panel reuses --popover-* (owned in _popover.css) */ @layer variables { :root { /* Box treatment — alias the shared --field-* family (owned in _fields.css) so a select, a text input, and a textarea read as one family. Retune a --field-* token and every control follows; remap a --select-* token to diverge just the select. */ --select-inline-size: 100%; --select-min-inline-size: 16ch; --select-padding-inline-start: var(--field-padding); /* Trailing space differs from .input — room for the picker icon */ --select-padding-inline-end: var(--step-2); --select-font-size: var(--field-font-size); --select-line-height: var(--field-line-height); --select-border: 1px solid var(--field-border); --select-border-radius: var(--field-radius); /* Picker icon (trigger chevron) ** @see https://www.svgbackgrounds.com/tools/svg-to-css/ ** Set to Legacy / URL Wrapper to update icon */ --select-picker-icon-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Crect width='256' height='256' fill='none'/%3E%3Cpolyline points='80 176 128 224 176 176' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'/%3E%3Cpolyline points='80 80 128 32 176 80' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'/%3E%3C/svg%3E"); --select-picker-icon-size: var(--field-icon-size); --select-picker-icon-color: currentColor; /* Option (menu item) — a ghost button rendered as a native <option>. Alias the --button-* family (owned in _button.css) so a select's options and a dropdown's ghost menu items read as one; retune a --button-* token and both follow. The ghost look is base button minus the surface fill + border, so only --option-background/--option-border diverge. Radius bridges to the shared --popover-button-radius like every other [popover]-surface button. */ --option-font-family: var(--button-font-family); --option-font-size: var(--button-font-size); --option-font-weight: var(--button-font-weight); --option-line-height: var(--button-line-height); --option-gap: var(--button-gap); --option-padding: var(--button-padding); --option-height: var(--button-height); --option-icon-size: var(--button-icon-size); --option-radius: var(--popover-button-radius); --option-shadow: none; --option-background: transparent; --option-background--hover: var(--button-background--hover); --option-background--active: var(--button-background--active); --option-foreground: var(--button-foreground); --option-foreground--hover: var(--button-foreground--hover); --option-foreground--active: var(--button-foreground--active); --option-border: 1px solid transparent; --option-border--hover: 1px solid transparent; --option-border--active: 1px solid transparent; /* Option checkmark ** @see https://www.svgbackgrounds.com/tools/svg-to-css/ ** Set to Legacy / URL Wrapper to update icon */ --option-checkmark-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Crect width='256' height='256' fill='none'/%3E%3Cpolyline points='40 144 96 200 224 72' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'/%3E%3C/svg%3E"); --option-checkmark-size: var(--field-icon-size); --option-checkmark-color: currentColor; /* Reveal + surface for the dropdown panel are shared with [popover] — the picker reads the --popover-* family (owned in _popover.css) so a select's menu and a popover read as one surface. Retune a --popover-* token and both follow; override here to diverge just the select's picker. */ }}@layer reset { /* =========================================================================== SELECT — native <select> internals (::picker, <option>, ::picker-icon) - In the reset layer so component overrides win; the plain appearance reset stays in _reset.css. - Both the select and ::picker(select) need appearance: base-select before picker pseudo styles apply (MDN customizable select). =========================================================================== */ :where(.select), :where(.select)::picker(select) { appearance: base-select; } :where(.select)::picker(select) { transform-origin: var(--popover-origin); opacity: var(--popover-opacity--start); backdrop-filter: var(--popover-backdrop-filter); transform: scale(var(--popover-scale--start)) translate3d(var(--popover-translate--start)); transition: opacity var(--default-transition-duration) var(--default-transition-timing-function), transform var(--default-transition-duration) var(--default-transition-timing-function), padding var(--default-transition-duration) var(--default-transition-timing-function), block-size var(--default-transition-duration) var(--default-transition-timing-function), inline-size var(--default-transition-duration) var(--default-transition-timing-function), overlay var(--default-transition-duration) var(--default-transition-timing-function) allow-discrete, display var(--default-transition-duration) var(--default-transition-timing-function) allow-discrete; } :where(.select:open)::picker(select) { opacity: var(--popover-opacity--end); transform: scale(var(--popover-scale--end)) translate3d(var(--popover-translate--end)); display: flex; } @starting-style { :where(.select:open)::picker(select) { opacity: var(--popover-opacity--start); transform: scale(var(--popover-scale--start)) translate3d(var(--popover-translate--start)); } } :where(option)::checkmark { content: ""; inline-size: var(--option-checkmark-size); block-size: var(--option-checkmark-size); background-color: var(--option-checkmark-color); mask: var(--option-checkmark-mask) center / contain no-repeat; } :where(.select)::picker-icon { content: ""; inline-size: var(--select-picker-icon-size); block-size: var(--select-picker-icon-size); background-color: var(--select-picker-icon-color); mask: var(--select-picker-icon-mask) center / contain no-repeat; }}@layer zazz.components { /* =========================================================================== SELECT — .select (customizable <select>, no JS) - appearance: base-select themes the trigger, picker, options, and arrow while the browser keeps keyboard nav, top-layer rendering, and form integration. - Needs a <button><selectedcontent></selectedcontent></button> child. Degrades to the native OS dropdown where unsupported (the _reset.css chevron covers it). - Shares the --field-* tokens defined in _fields.css. =========================================================================== */ .select { display: flex; align-items: center; justify-content: space-between; gap: var(--field-gap); inline-size: var(--select-inline-size); min-inline-size: var(--select-min-inline-size); block-size: var(--field-height); padding-inline: var(--select-padding-inline-start) var(--select-padding-inline-end); font-size: var(--select-font-size); line-height: var(--select-line-height); text-align: start; color: var(--field-foreground); background-color: var(--field-background); background-image: none; border: var(--select-border); border-radius: var(--select-border-radius); cursor: pointer; /* ring renders as box-shadow; transparent outline twin keeps focus visible in forced-colors / high-contrast modes */ --_ring-offset-width: 0px; --_ring-width: 0px; box-shadow: 0 0 0 var(--_ring-offset-width) var(--ring-offset-color), 0 0 0 calc(var(--_ring-offset-width) + var(--_ring-width)) var(--ring); outline: var(--outline-width) var(--outline-style) transparent; outline-offset: var(--outline-offset); transition: var(--default-transition); } .select:hover { border-color: var(--field-border--hover); } .select:focus-visible, .select:open { border-color: var(--field-border--focus); --_ring-offset-width: var(--ring-offset-width); --_ring-width: var(--ring-width); outline-color: transparent; } /* Trigger button child holds the (optional) leading icon + mirrored value */ .select>button { block-size: 100%; display: flex; flex: 1; align-items: center; gap: var(--field-gap); min-inline-size: 0; overflow: hidden; } .select selectedcontent { display: flex; align-items: center; block-size: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* Trigger icons (leading glyph + mirrored value) size to the field icon */ .select>button>svg, .select selectedcontent>svg { flex-shrink: 0; inline-size: var(--field-icon-size); block-size: var(--field-icon-size); color: var(--muted-foreground); } .select:open { background-color: var(--field-background--focus); } .select:open::picker-icon { transform: rotate(-180deg); } /* Dropdown panel — the shared [popover] surface (--popover-* family), anchored to the trigger. anchor-size() matches the panel to the trigger width; it is anchor-related, so it is gated. The fallback (max-content) only matters in the narrow case of a browser that supports customizable <select> (::picker) but not anchor-size — there the panel sizes to its content instead of stretching. */ .select::picker(select) { flex-direction: column; gap: var(--step-px); inline-size: max-content; max-inline-size: var(--popover-max-inline-size); padding: var(--popover-padding); margin-block: var(--popover-margin-block); background-color: var(--popover); color: var(--popover-foreground); border: 1px solid var(--popover-border); border-radius: var(--popover-radius); box-shadow: var(--popover-shadow); } @supports (inline-size: anchor-size(width)) { .select::picker(select) { min-inline-size: anchor-size(width); } } /* =========================================================================== PICKER PLACEMENT — data-side × data-align, mirroring [popover] (row/block first, column/inline second per MDN). Set on the .select; drives the --popover-* tokens the surface + @supports block below consume. Defaults to bottom / start (no attributes) so the picker keeps dropping below the trigger. =========================================================================== */ /* bottom (default side) */ .select:not([data-side]):not([data-align])::picker(select), .select:not([data-side])[data-align="start"]::picker(select), .select[data-side="bottom"]:not([data-align])::picker(select), .select[data-side="bottom"][data-align="start"]::picker(select) { --popover-position-area: block-end span-inline-end; --popover-origin: top center; --popover-margin-block: var(--step-1_5); --popover-margin-inline: 0; } .select:not([data-side])[data-align="center"]::picker(select), .select[data-side="bottom"][data-align="center"]::picker(select) { --popover-position-area: block-end span-all; --popover-origin: top center; --popover-margin-block: var(--step-1_5); --popover-margin-inline: 0; } .select:not([data-side])[data-align="end"]::picker(select), .select[data-side="bottom"][data-align="end"]::picker(select) { --popover-position-area: block-end span-inline-start; --popover-origin: top center; --popover-margin-block: var(--step-1_5); --popover-margin-inline: 0; } /* top */ .select[data-side="top"]:not([data-align])::picker(select), .select[data-side="top"][data-align="start"]::picker(select) { --popover-position-area: block-start span-inline-end; --popover-origin: bottom center; --popover-margin-block: var(--step-1_5); --popover-margin-inline: 0; } .select[data-side="top"][data-align="center"]::picker(select) { --popover-position-area: block-start span-all; --popover-origin: bottom center; --popover-margin-block: var(--step-1_5); --popover-margin-inline: 0; } .select[data-side="top"][data-align="end"]::picker(select) { --popover-position-area: block-start span-inline-start; --popover-origin: bottom center; --popover-margin-block: var(--step-1_5); --popover-margin-inline: 0; } /* right */ .select[data-side="right"]:not([data-align])::picker(select), .select[data-side="right"][data-align="start"]::picker(select) { --popover-position-area: inline-end span-block-end; --popover-origin: bottom left; --popover-margin-block: 0; --popover-margin-inline: var(--step-1_5); } .select[data-side="right"][data-align="center"]::picker(select) { --popover-position-area: inline-end span-all; --popover-origin: center left; --popover-margin-block: 0; --popover-margin-inline: var(--step-1_5); } .select[data-side="right"][data-align="end"]::picker(select) { --popover-position-area: inline-end span-block-start; --popover-origin: top left; --popover-margin-block: 0; --popover-margin-inline: var(--step-1_5); } /* left */ .select[data-side="left"]:not([data-align])::picker(select), .select[data-side="left"][data-align="start"]::picker(select) { --popover-position-area: inline-start span-block-end; --popover-origin: bottom right; --popover-margin-block: 0; --popover-margin-inline: var(--step-1_5); } .select[data-side="left"][data-align="center"]::picker(select) { --popover-position-area: inline-start span-all; --popover-origin: center right; --popover-margin-block: 0; --popover-margin-inline: var(--step-1_5); } .select[data-side="left"][data-align="end"]::picker(select) { --popover-position-area: inline-start span-block-start; --popover-origin: top right; --popover-margin-block: 0; --popover-margin-inline: var(--step-1_5); } /* The picker is implicitly anchored to the select, so position-area needs no anchor-name — but it is still anchor positioning, gated like _popover.css so browsers without it (Firefox/Safari) keep the UA picker placement instead of a mispositioned box. */ @supports (anchor-name: --zazz) { .select::picker(select) { position-area: var(--popover-position-area); position-try-fallbacks: var(--popover-position-try-fallbacks); margin-inline: var(--popover-margin-inline); } } /* Options render as ghost buttons — the --option-* family (set in @layer variables) aliases the --button-* ghost values, so a select's menu items and a dropdown's ghost menu items read identically. Like .button, sizing rides on min-block-size + padding-inline (no block padding) so every row is uniform. */ .select option { display: flex; align-items: center; gap: var(--option-gap); min-block-size: var(--option-height); padding-inline: var(--option-padding); font-family: var(--option-font-family); font-size: var(--option-font-size); font-weight: var(--option-font-weight); line-height: var(--option-line-height); color: var(--option-foreground); background-color: var(--option-background); border: var(--option-border); border-radius: var(--option-radius); box-shadow: var(--option-shadow); cursor: pointer; transition: var(--default-transition); } .select option:hover { color: var(--option-foreground--hover); background-color: var(--option-background--hover); border: var(--option-border--hover); } .select option:checked { color: var(--option-foreground--active); background-color: var(--option-background--active); border: var(--option-border--active); } .select option:disabled { --option-foreground: var(--muted-foreground); cursor: default; opacity: 0.5; } .select option>svg { flex-shrink: 0; inline-size: var(--option-icon-size); block-size: var(--option-icon-size); color: var(--muted-foreground); } .select option::checkmark { order: 1; margin-inline-start: auto; color: currentcolor; }}Alignment
<div class="flex gap-sm items-start"> <div class="field"> <label class="field__label" for="f-align-start">Start</label> <select class="select" id="f-align-start" name="align-start" data-align="start"> <button> <selectedcontent></selectedcontent> </button> <optgroup class="grid gap-px" label="Sort by"> <legend class="text-eyebrow text-muted-foreground p-xs">Sort by</legend> <option value="recent" selected>Most recent</option> <option value="popular">Most popular</option> <option value="price-asc">Price: low to high</option> <option value="price-desc">Price: high to low</option> </optgroup> </select> </div> <div class="field"> <label class="field__label" for="f-align-center">Center</label> <select class="select" id="f-align-center" name="align-center" data-align="center"> <button> <selectedcontent></selectedcontent> </button> <optgroup class="grid gap-px" label="Sort by"> <legend class="text-eyebrow text-muted-foreground p-xs">Sort by</legend> <option value="recent" selected>Most recent</option> <option value="popular">Most popular</option> <option value="price-asc">Price: low to high</option> <option value="price-desc">Price: high to low</option> </optgroup> </select> </div> <div class="field"> <label class="field__label" for="f-align-end">End</label> <select class="select" id="f-align-end" name="align-end" data-align="end"> <button> <selectedcontent></selectedcontent> </button> <optgroup class="grid gap-px" label="Sort by"> <legend class="text-eyebrow text-muted-foreground p-xs">Sort by</legend> <option value="recent" selected>Most recent</option> <option value="popular">Most popular</option> <option value="price-asc">Price: low to high</option> <option value="price-desc">Price: high to low</option> </optgroup> </select> </div></div>/** * _select.css — Select (.select on customizable <select>) * * @layer variables, reset, components * @requires layers.css, _variables.css, _fields.css, _reset.css, _popover.css (--popover-*), _button.css (--button-*) * @uses appearance: base-select — customizable select (Chromium 135+ / Safari 17+) * @uses ::picker(select), ::picker-icon, selectedcontent, option::checkmark * @uses @starting-style + allow-discrete — picker open animation * @uses anchor-size(width) — match picker width to trigger (@supports gated) * @uses position-area, position-try-fallbacks — data-side × data-align picker placement (@supports gated) * @uses backdrop-filter — picker surface blur (shared with [popover]) * @uses Degrades to native OS dropdown where base-select is unsupported * @tokens --select-* (@layer variables); picker panel reuses --popover-* (owned in _popover.css) */ @layer variables { :root { /* Box treatment — alias the shared --field-* family (owned in _fields.css) so a select, a text input, and a textarea read as one family. Retune a --field-* token and every control follows; remap a --select-* token to diverge just the select. */ --select-inline-size: 100%; --select-min-inline-size: 16ch; --select-padding-inline-start: var(--field-padding); /* Trailing space differs from .input — room for the picker icon */ --select-padding-inline-end: var(--step-2); --select-font-size: var(--field-font-size); --select-line-height: var(--field-line-height); --select-border: 1px solid var(--field-border); --select-border-radius: var(--field-radius); /* Picker icon (trigger chevron) ** @see https://www.svgbackgrounds.com/tools/svg-to-css/ ** Set to Legacy / URL Wrapper to update icon */ --select-picker-icon-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Crect width='256' height='256' fill='none'/%3E%3Cpolyline points='80 176 128 224 176 176' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'/%3E%3Cpolyline points='80 80 128 32 176 80' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'/%3E%3C/svg%3E"); --select-picker-icon-size: var(--field-icon-size); --select-picker-icon-color: currentColor; /* Option (menu item) — a ghost button rendered as a native <option>. Alias the --button-* family (owned in _button.css) so a select's options and a dropdown's ghost menu items read as one; retune a --button-* token and both follow. The ghost look is base button minus the surface fill + border, so only --option-background/--option-border diverge. Radius bridges to the shared --popover-button-radius like every other [popover]-surface button. */ --option-font-family: var(--button-font-family); --option-font-size: var(--button-font-size); --option-font-weight: var(--button-font-weight); --option-line-height: var(--button-line-height); --option-gap: var(--button-gap); --option-padding: var(--button-padding); --option-height: var(--button-height); --option-icon-size: var(--button-icon-size); --option-radius: var(--popover-button-radius); --option-shadow: none; --option-background: transparent; --option-background--hover: var(--button-background--hover); --option-background--active: var(--button-background--active); --option-foreground: var(--button-foreground); --option-foreground--hover: var(--button-foreground--hover); --option-foreground--active: var(--button-foreground--active); --option-border: 1px solid transparent; --option-border--hover: 1px solid transparent; --option-border--active: 1px solid transparent; /* Option checkmark ** @see https://www.svgbackgrounds.com/tools/svg-to-css/ ** Set to Legacy / URL Wrapper to update icon */ --option-checkmark-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Crect width='256' height='256' fill='none'/%3E%3Cpolyline points='40 144 96 200 224 72' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'/%3E%3C/svg%3E"); --option-checkmark-size: var(--field-icon-size); --option-checkmark-color: currentColor; /* Reveal + surface for the dropdown panel are shared with [popover] — the picker reads the --popover-* family (owned in _popover.css) so a select's menu and a popover read as one surface. Retune a --popover-* token and both follow; override here to diverge just the select's picker. */ }}@layer reset { /* =========================================================================== SELECT — native <select> internals (::picker, <option>, ::picker-icon) - In the reset layer so component overrides win; the plain appearance reset stays in _reset.css. - Both the select and ::picker(select) need appearance: base-select before picker pseudo styles apply (MDN customizable select). =========================================================================== */ :where(.select), :where(.select)::picker(select) { appearance: base-select; } :where(.select)::picker(select) { transform-origin: var(--popover-origin); opacity: var(--popover-opacity--start); backdrop-filter: var(--popover-backdrop-filter); transform: scale(var(--popover-scale--start)) translate3d(var(--popover-translate--start)); transition: opacity var(--default-transition-duration) var(--default-transition-timing-function), transform var(--default-transition-duration) var(--default-transition-timing-function), padding var(--default-transition-duration) var(--default-transition-timing-function), block-size var(--default-transition-duration) var(--default-transition-timing-function), inline-size var(--default-transition-duration) var(--default-transition-timing-function), overlay var(--default-transition-duration) var(--default-transition-timing-function) allow-discrete, display var(--default-transition-duration) var(--default-transition-timing-function) allow-discrete; } :where(.select:open)::picker(select) { opacity: var(--popover-opacity--end); transform: scale(var(--popover-scale--end)) translate3d(var(--popover-translate--end)); display: flex; } @starting-style { :where(.select:open)::picker(select) { opacity: var(--popover-opacity--start); transform: scale(var(--popover-scale--start)) translate3d(var(--popover-translate--start)); } } :where(option)::checkmark { content: ""; inline-size: var(--option-checkmark-size); block-size: var(--option-checkmark-size); background-color: var(--option-checkmark-color); mask: var(--option-checkmark-mask) center / contain no-repeat; } :where(.select)::picker-icon { content: ""; inline-size: var(--select-picker-icon-size); block-size: var(--select-picker-icon-size); background-color: var(--select-picker-icon-color); mask: var(--select-picker-icon-mask) center / contain no-repeat; }}@layer zazz.components { /* =========================================================================== SELECT — .select (customizable <select>, no JS) - appearance: base-select themes the trigger, picker, options, and arrow while the browser keeps keyboard nav, top-layer rendering, and form integration. - Needs a <button><selectedcontent></selectedcontent></button> child. Degrades to the native OS dropdown where unsupported (the _reset.css chevron covers it). - Shares the --field-* tokens defined in _fields.css. =========================================================================== */ .select { display: flex; align-items: center; justify-content: space-between; gap: var(--field-gap); inline-size: var(--select-inline-size); min-inline-size: var(--select-min-inline-size); block-size: var(--field-height); padding-inline: var(--select-padding-inline-start) var(--select-padding-inline-end); font-size: var(--select-font-size); line-height: var(--select-line-height); text-align: start; color: var(--field-foreground); background-color: var(--field-background); background-image: none; border: var(--select-border); border-radius: var(--select-border-radius); cursor: pointer; /* ring renders as box-shadow; transparent outline twin keeps focus visible in forced-colors / high-contrast modes */ --_ring-offset-width: 0px; --_ring-width: 0px; box-shadow: 0 0 0 var(--_ring-offset-width) var(--ring-offset-color), 0 0 0 calc(var(--_ring-offset-width) + var(--_ring-width)) var(--ring); outline: var(--outline-width) var(--outline-style) transparent; outline-offset: var(--outline-offset); transition: var(--default-transition); } .select:hover { border-color: var(--field-border--hover); } .select:focus-visible, .select:open { border-color: var(--field-border--focus); --_ring-offset-width: var(--ring-offset-width); --_ring-width: var(--ring-width); outline-color: transparent; } /* Trigger button child holds the (optional) leading icon + mirrored value */ .select>button { block-size: 100%; display: flex; flex: 1; align-items: center; gap: var(--field-gap); min-inline-size: 0; overflow: hidden; } .select selectedcontent { display: flex; align-items: center; block-size: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* Trigger icons (leading glyph + mirrored value) size to the field icon */ .select>button>svg, .select selectedcontent>svg { flex-shrink: 0; inline-size: var(--field-icon-size); block-size: var(--field-icon-size); color: var(--muted-foreground); } .select:open { background-color: var(--field-background--focus); } .select:open::picker-icon { transform: rotate(-180deg); } /* Dropdown panel — the shared [popover] surface (--popover-* family), anchored to the trigger. anchor-size() matches the panel to the trigger width; it is anchor-related, so it is gated. The fallback (max-content) only matters in the narrow case of a browser that supports customizable <select> (::picker) but not anchor-size — there the panel sizes to its content instead of stretching. */ .select::picker(select) { flex-direction: column; gap: var(--step-px); inline-size: max-content; max-inline-size: var(--popover-max-inline-size); padding: var(--popover-padding); margin-block: var(--popover-margin-block); background-color: var(--popover); color: var(--popover-foreground); border: 1px solid var(--popover-border); border-radius: var(--popover-radius); box-shadow: var(--popover-shadow); } @supports (inline-size: anchor-size(width)) { .select::picker(select) { min-inline-size: anchor-size(width); } } /* =========================================================================== PICKER PLACEMENT — data-side × data-align, mirroring [popover] (row/block first, column/inline second per MDN). Set on the .select; drives the --popover-* tokens the surface + @supports block below consume. Defaults to bottom / start (no attributes) so the picker keeps dropping below the trigger. =========================================================================== */ /* bottom (default side) */ .select:not([data-side]):not([data-align])::picker(select), .select:not([data-side])[data-align="start"]::picker(select), .select[data-side="bottom"]:not([data-align])::picker(select), .select[data-side="bottom"][data-align="start"]::picker(select) { --popover-position-area: block-end span-inline-end; --popover-origin: top center; --popover-margin-block: var(--step-1_5); --popover-margin-inline: 0; } .select:not([data-side])[data-align="center"]::picker(select), .select[data-side="bottom"][data-align="center"]::picker(select) { --popover-position-area: block-end span-all; --popover-origin: top center; --popover-margin-block: var(--step-1_5); --popover-margin-inline: 0; } .select:not([data-side])[data-align="end"]::picker(select), .select[data-side="bottom"][data-align="end"]::picker(select) { --popover-position-area: block-end span-inline-start; --popover-origin: top center; --popover-margin-block: var(--step-1_5); --popover-margin-inline: 0; } /* top */ .select[data-side="top"]:not([data-align])::picker(select), .select[data-side="top"][data-align="start"]::picker(select) { --popover-position-area: block-start span-inline-end; --popover-origin: bottom center; --popover-margin-block: var(--step-1_5); --popover-margin-inline: 0; } .select[data-side="top"][data-align="center"]::picker(select) { --popover-position-area: block-start span-all; --popover-origin: bottom center; --popover-margin-block: var(--step-1_5); --popover-margin-inline: 0; } .select[data-side="top"][data-align="end"]::picker(select) { --popover-position-area: block-start span-inline-start; --popover-origin: bottom center; --popover-margin-block: var(--step-1_5); --popover-margin-inline: 0; } /* right */ .select[data-side="right"]:not([data-align])::picker(select), .select[data-side="right"][data-align="start"]::picker(select) { --popover-position-area: inline-end span-block-end; --popover-origin: bottom left; --popover-margin-block: 0; --popover-margin-inline: var(--step-1_5); } .select[data-side="right"][data-align="center"]::picker(select) { --popover-position-area: inline-end span-all; --popover-origin: center left; --popover-margin-block: 0; --popover-margin-inline: var(--step-1_5); } .select[data-side="right"][data-align="end"]::picker(select) { --popover-position-area: inline-end span-block-start; --popover-origin: top left; --popover-margin-block: 0; --popover-margin-inline: var(--step-1_5); } /* left */ .select[data-side="left"]:not([data-align])::picker(select), .select[data-side="left"][data-align="start"]::picker(select) { --popover-position-area: inline-start span-block-end; --popover-origin: bottom right; --popover-margin-block: 0; --popover-margin-inline: var(--step-1_5); } .select[data-side="left"][data-align="center"]::picker(select) { --popover-position-area: inline-start span-all; --popover-origin: center right; --popover-margin-block: 0; --popover-margin-inline: var(--step-1_5); } .select[data-side="left"][data-align="end"]::picker(select) { --popover-position-area: inline-start span-block-start; --popover-origin: top right; --popover-margin-block: 0; --popover-margin-inline: var(--step-1_5); } /* The picker is implicitly anchored to the select, so position-area needs no anchor-name — but it is still anchor positioning, gated like _popover.css so browsers without it (Firefox/Safari) keep the UA picker placement instead of a mispositioned box. */ @supports (anchor-name: --zazz) { .select::picker(select) { position-area: var(--popover-position-area); position-try-fallbacks: var(--popover-position-try-fallbacks); margin-inline: var(--popover-margin-inline); } } /* Options render as ghost buttons — the --option-* family (set in @layer variables) aliases the --button-* ghost values, so a select's menu items and a dropdown's ghost menu items read identically. Like .button, sizing rides on min-block-size + padding-inline (no block padding) so every row is uniform. */ .select option { display: flex; align-items: center; gap: var(--option-gap); min-block-size: var(--option-height); padding-inline: var(--option-padding); font-family: var(--option-font-family); font-size: var(--option-font-size); font-weight: var(--option-font-weight); line-height: var(--option-line-height); color: var(--option-foreground); background-color: var(--option-background); border: var(--option-border); border-radius: var(--option-radius); box-shadow: var(--option-shadow); cursor: pointer; transition: var(--default-transition); } .select option:hover { color: var(--option-foreground--hover); background-color: var(--option-background--hover); border: var(--option-border--hover); } .select option:checked { color: var(--option-foreground--active); background-color: var(--option-background--active); border: var(--option-border--active); } .select option:disabled { --option-foreground: var(--muted-foreground); cursor: default; opacity: 0.5; } .select option>svg { flex-shrink: 0; inline-size: var(--option-icon-size); block-size: var(--option-icon-size); color: var(--muted-foreground); } .select option::checkmark { order: 1; margin-inline-start: auto; color: currentcolor; }}API
| Attribute | Where | Values |
|---|---|---|
data-side | .select | top, bottom, left, right |
data-align | .select | start, center, end |