Select
Native select control styled using the customizable select API and anchor positioning.
.ui-select styles native <select> controls using appearance: base-select. Falls back to the standard OS dropdown where unsupported.
Requires a <button><selectedcontent></selectedcontent></button> as the first child of the <select> to serve as the trigger and mirror selection.
Default
<div class="ui-field"> <label data-slot="field-label" for="f-sort">Sort by</label> <select class="ui-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 (.ui-select on customizable <select>) * * @layer variables, reset, components * @requires layers.css, _variables.css, fields.css, _reset.css, popover.css (--ui-popover-*), button.css (--ui-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: placement tokens come from * popover.css's shared matrix (@supports gated) * @uses backdrop-filter: picker surface blur (shared with [popover]) * @uses Degrades to native OS dropdown where base-select is unsupported * @uses ui-multiselect (multiselect.ts): JS-projected dropdown for * <select multiple>; base-select has no stable multi-select yet, so * this is a recorded exception like base-select itself. Degrades to * the native multi listbox without JS; retire the enhancement when * base-select-for-multiple ships stable. * @tokens --ui-select-* (@layer variables); picker panel reuses --ui-popover-* (owned in popover.css) * @consumedby autocomplete.css, combobox.css, command.css (--ui-option-*, --ui-select-picker-icon-*) */@layer variables { :root { /* Box treatment: alias the shared --ui-field-* family (owned in fields.css) so a select, a text input, and a textarea read as one family. Retune a --ui-field-* token and every control follows; remap a --ui-select-* token to diverge just the select. */ --ui-select-inline-size: 100%; --ui-select-min-inline-size: 16ch; --ui-select-padding-inline-start: var(--ui-field-padding); /* Trailing space differs from .ui-input: room for the picker icon */ --ui-select-padding-inline-end: var(--step-2); --ui-select-font-size: var(--ui-field-font-size); --ui-select-line-height: var(--ui-field-line-height); --ui-select-border: 1px solid var(--ui-field-border); --ui-select-border-radius: var(--ui-field-radius); --ui-select-option-gap: var(--ui-field-option-gap); /* Picker icon (trigger chevron) ** @see https://www.svgbackgrounds.com/tools/svg-to-css/ ** Set to Legacy / URL Wrapper to update icon */ --ui-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"); --ui-select-picker-icon-size: var(--ui-field-icon-size); --ui-select-picker-icon-color: currentColor; /* Option (menu item): a ghost button rendered as a native <option>. Alias the --ui-button-* family (owned in button.css) so a select's options and a menu's ghost items read as one; retune a --ui-button-* token and both follow. The ghost look is base button minus the surface fill + border, so only --ui-option-background/--ui-option-border diverge. Radius bridges to the shared --ui-menu-button-radius. */ --ui-option-font-family: var(--ui-button-font-family); --ui-option-font-size: var(--ui-button-font-size); --ui-option-font-weight: var(--ui-button-font-weight); --ui-option-line-height: var(--ui-button-line-height); --ui-option-gap: var(--ui-button-gap); --ui-option-padding: var(--ui-button-padding); --ui-option-height: var(--ui-button-height); --ui-option-icon-size: var(--ui-button-icon-size); --ui-option-radius: var(--ui-menu-button-radius); --ui-option-shadow: none; --ui-option-background: transparent; --ui-option-background--hover: var(--ui-button-background--hover); --ui-option-background--active: var(--ui-button-background--active); --ui-option-foreground: var(--ui-button-foreground); --ui-option-foreground--hover: var(--ui-button-foreground--hover); --ui-option-foreground--active: var(--ui-button-foreground--active); --ui-option-border: 1px solid transparent; --ui-option-border--hover: 1px solid transparent; --ui-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 */ --ui-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"); --ui-option-checkmark-size: var(--ui-field-icon-size); --ui-option-checkmark-color: currentColor; }}@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(.ui-select), :where(.ui-select)::picker(select) { appearance: base-select; } :where(.ui-select)::picker(select) { transform-origin: var(--ui-popover-origin); opacity: var(--ui-popover-opacity--start); backdrop-filter: var(--ui-popover-backdrop-filter); transform: scale(var(--ui-popover-scale--start)) translate3d(var(--ui-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(.ui-select:open)::picker(select) { opacity: var(--ui-popover-opacity--end); transform: scale(var(--ui-popover-scale--end)) translate3d(var(--ui-popover-translate--end)); display: flex; } @starting-style { :where(.ui-select:open)::picker(select) { opacity: var(--ui-popover-opacity--start); transform: scale(var(--ui-popover-scale--start)) translate3d(var(--ui-popover-translate--start)); } } :where(option)::checkmark { content: ""; inline-size: var(--ui-option-checkmark-size); block-size: var(--ui-option-checkmark-size); background-color: var(--ui-option-checkmark-color); mask: var(--ui-option-checkmark-mask) center / contain no-repeat; } :where(.ui-select)::picker-icon { content: ""; inline-size: var(--ui-select-picker-icon-size); block-size: var(--ui-select-picker-icon-size); background-color: var(--ui-select-picker-icon-color); mask: var(--ui-select-picker-icon-mask) center / contain no-repeat; }}@layer zazz.components { /* =========================================================================== SELECT: .ui-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 --ui-field-* tokens defined in fields.css. =========================================================================== */ .ui-select { display: flex; align-items: center; justify-content: space-between; gap: var(--ui-select-option-gap); inline-size: var(--ui-select-inline-size); min-inline-size: var(--ui-select-min-inline-size); block-size: var(--ui-field-height); padding-inline: var(--ui-select-padding-inline-start) var(--ui-select-padding-inline-end); font-size: var(--ui-select-font-size); line-height: var(--ui-select-line-height); text-align: start; color: var(--ui-field-foreground); background-color: var(--ui-field-background); background-image: none; border: var(--ui-select-border); border-radius: var(--ui-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; --_ring: color-mix(in oklch, var(--ui-field-ring-color) var(--ring-opacity), transparent); 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, var(--ring)); outline: var(--outline-width) var(--outline-style) transparent; outline-offset: var(--outline-offset); transition: var(--default-transition); } .ui-select:hover { border-color: var(--ui-field-border--hover); } .ui-select:focus-visible, .ui-select:open { border-color: var(--ui-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 */ .ui-select > button { block-size: 100%; display: flex; flex: 1; align-items: center; gap: var(--ui-field-gap); min-inline-size: 0; overflow: hidden; } .ui-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 */ .ui-select > button > svg, .ui-select selectedcontent > svg { flex-shrink: 0; inline-size: var(--ui-field-icon-size); block-size: var(--ui-field-icon-size); color: var(--muted-foreground); } .ui-select:open { background-color: var(--ui-field-background--focus); } .ui-select:open::picker-icon { transform: rotate(-180deg); } /* Dropdown panel: the shared [popover] surface (--ui-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, where the panel sizes to its content instead of stretching. */ .ui-select::picker(select) { flex-direction: column; gap: var(--step-px); inline-size: max-content; max-inline-size: var(--ui-popover-max-inline-size); padding: var(--ui-popover-padding); margin-block: var(--ui-popover-margin-block); overflow: clip; background-color: var(--popover); color: var(--popover-foreground); border: 1px solid var(--ui-popover-border); border-radius: var(--ui-popover-radius); box-shadow: var(--ui-popover-shadow); } @supports (inline-size: anchor-size(width)) { .ui-select::picker(select) { min-inline-size: anchor-size(width); } } /* =========================================================================== PICKER PLACEMENT: the shared matrix in popover.css owns data-side x data-align (it includes .ui-select, and custom properties set on the select inherit into ::picker(select)). Only the application of those tokens lives here, because position-area is gated on anchor support. =========================================================================== */ /* 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) { .ui-select::picker(select) { position-area: var(--ui-popover-position-area); position-try-fallbacks: var(--ui-popover-position-try-fallbacks); margin-inline: var(--ui-popover-margin-inline); } } /* Options render as ghost buttons: the --ui-option-* family (set in @layer variables) aliases the --ui-button-* ghost values, so a select's menu items and a menu's ghost items read identically. Like .ui-button, sizing rides on min-block-size + padding-inline (no block padding) so every row is uniform. */ .ui-select option { display: flex; align-items: center; gap: var(--ui-option-gap); min-block-size: var(--ui-option-height); padding-inline: var(--ui-option-padding); font-family: var(--ui-option-font-family); font-size: var(--ui-option-font-size); font-weight: var(--ui-option-font-weight); line-height: var(--ui-option-line-height); color: var(--ui-option-foreground); background-color: var(--ui-option-background); border: var(--ui-option-border); border-radius: var(--ui-option-radius); box-shadow: var(--ui-option-shadow); cursor: pointer; transition: var(--default-transition); } .ui-select option:hover { color: var(--ui-option-foreground--hover); background-color: var(--ui-option-background--hover); border: var(--ui-option-border--hover); } .ui-select option:checked { color: var(--ui-option-foreground--active); background-color: var(--ui-option-background--active); border: var(--ui-option-border--active); } .ui-select option:disabled { --ui-option-foreground: var(--muted-foreground); cursor: default; opacity: 0.5; } .ui-select option > svg { flex-shrink: 0; inline-size: var(--ui-option-icon-size); block-size: var(--ui-option-icon-size); color: var(--muted-foreground); } .ui-select option::checkmark { order: 1; margin-inline-start: auto; color: currentcolor; } /* =========================================================================== MULTISELECT: ui-multiselect (JS-projected dropdown over <select multiple>) - The script hides the real select and stamps a .ui-select-classed trigger + a popover of checkbox rows (see multiselect.ts). Rows are ghost options (--ui-option-*) with the shared checkmark, so single- and multi-select pickers read identically. - Without JS the native multi listbox renders; it gets minimal field-family dress below rather than the single-select trigger treatment. =========================================================================== */ :where(ui-multiselect) { anchor-scope: --ui-multiselect-trigger; display: block; inline-size: var(--ui-select-inline-size); min-inline-size: var(--ui-select-min-inline-size); } /* Enhanced: the real select stays in the DOM as the form value carrier */ select[multiple][data-multiselect-enhanced] { display: none; } /* No-JS baseline: the native listbox wears the field surface, sized by rows */ ui-multiselect select[multiple].ui-select:not([data-multiselect-enhanced]) { display: block; block-size: auto; padding-block: var(--step-1); } [data-slot~="multiselect-trigger"] { anchor-name: --ui-multiselect-trigger; } [data-slot~="multiselect-label"] { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* Chevron: the ::picker-icon treatment, rebuilt for a plain button */ [data-slot~="multiselect-icon"] { flex-shrink: 0; inline-size: var(--ui-select-picker-icon-size); block-size: var(--ui-select-picker-icon-size); background-color: var(--ui-select-picker-icon-color); mask: var(--ui-select-picker-icon-mask) center / contain no-repeat; transition: var(--default-transition); } [data-slot~="multiselect-trigger"][aria-expanded="true"] [data-slot~="multiselect-icon"] { transform: rotate(-180deg); } [data-slot~="multiselect-panel"] { position-anchor: --ui-multiselect-trigger; /* Layout stays ungated so the close fade keeps it; only display is gated */ flex-direction: column; gap: var(--step-px); box-shadow: var(--ui-menu-shadow); } /* Only display is gated; see popover.css: closed popovers stay display: none */ [data-slot~="multiselect-panel"]:where(:popover-open, .\:popover-open) { display: flex; } @supports (inline-size: anchor-size(width)) { [data-slot~="multiselect-panel"] { min-inline-size: anchor-size(width); } } /* Rows are ghost options; the checkbox is real but visually replaced by the shared checkmark glyph */ [data-slot~="multiselect-option"] { display: flex; align-items: center; gap: var(--ui-option-gap); min-block-size: var(--ui-option-height); padding-inline: var(--ui-option-padding); font-family: var(--ui-option-font-family); font-size: var(--ui-option-font-size); font-weight: var(--ui-option-font-weight); line-height: var(--ui-option-line-height); color: var(--ui-option-foreground); background-color: var(--ui-option-background); border: var(--ui-option-border); border-radius: var(--ui-option-radius); cursor: pointer; transition: var(--default-transition); } [data-slot~="multiselect-option"]:hover { color: var(--ui-option-foreground--hover); background-color: var(--ui-option-background--hover); } [data-slot~="multiselect-option"]:has(:checked) { color: var(--ui-option-foreground--active); background-color: var(--ui-option-background--active); } [data-slot~="multiselect-option"]:has(:disabled) { color: var(--muted-foreground); cursor: default; opacity: 0.5; } /* The checkbox stays focusable and announced but invisible; keyboard focus surfaces on the row via :focus-within */ [data-slot~="multiselect-option"] input[type="checkbox"] { position: absolute; inline-size: 1px; block-size: 1px; overflow: clip; clip-path: inset(50%); white-space: nowrap; } [data-slot~="multiselect-option"]:focus-within { background-color: var(--ui-option-background--hover); outline: var(--outline-width) var(--outline-style) var(--ring); outline-offset: calc(var(--outline-offset) * -1); } [data-slot~="multiselect-option"]:has(:checked)::after { content: ""; order: 1; margin-inline-start: auto; inline-size: var(--ui-option-checkmark-size); block-size: var(--ui-option-checkmark-size); background-color: var(--ui-option-checkmark-color); mask: var(--ui-option-checkmark-mask) center / contain no-repeat; }}Placement
Use data-side and data-align on .ui-select to position the dropdown panel relative to the trigger button:
Sides
<div class="grid grid-cols-2 gap-sm place-items-center"> <div class="ui-field"> <label data-slot="field-label" for="f-side-top">Top</label> <select class="ui-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="ui-field"> <label data-slot="field-label" for="f-side-bottom">Bottom</label> <select class="ui-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="ui-field"> <label data-slot="field-label" for="f-side-left">Left</label> <select class="ui-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="ui-field"> <label data-slot="field-label" for="f-side-right">Right</label> <select class="ui-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 (.ui-select on customizable <select>) * * @layer variables, reset, components * @requires layers.css, _variables.css, fields.css, _reset.css, popover.css (--ui-popover-*), button.css (--ui-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: placement tokens come from * popover.css's shared matrix (@supports gated) * @uses backdrop-filter: picker surface blur (shared with [popover]) * @uses Degrades to native OS dropdown where base-select is unsupported * @uses ui-multiselect (multiselect.ts): JS-projected dropdown for * <select multiple>; base-select has no stable multi-select yet, so * this is a recorded exception like base-select itself. Degrades to * the native multi listbox without JS; retire the enhancement when * base-select-for-multiple ships stable. * @tokens --ui-select-* (@layer variables); picker panel reuses --ui-popover-* (owned in popover.css) * @consumedby autocomplete.css, combobox.css, command.css (--ui-option-*, --ui-select-picker-icon-*) */@layer variables { :root { /* Box treatment: alias the shared --ui-field-* family (owned in fields.css) so a select, a text input, and a textarea read as one family. Retune a --ui-field-* token and every control follows; remap a --ui-select-* token to diverge just the select. */ --ui-select-inline-size: 100%; --ui-select-min-inline-size: 16ch; --ui-select-padding-inline-start: var(--ui-field-padding); /* Trailing space differs from .ui-input: room for the picker icon */ --ui-select-padding-inline-end: var(--step-2); --ui-select-font-size: var(--ui-field-font-size); --ui-select-line-height: var(--ui-field-line-height); --ui-select-border: 1px solid var(--ui-field-border); --ui-select-border-radius: var(--ui-field-radius); --ui-select-option-gap: var(--ui-field-option-gap); /* Picker icon (trigger chevron) ** @see https://www.svgbackgrounds.com/tools/svg-to-css/ ** Set to Legacy / URL Wrapper to update icon */ --ui-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"); --ui-select-picker-icon-size: var(--ui-field-icon-size); --ui-select-picker-icon-color: currentColor; /* Option (menu item): a ghost button rendered as a native <option>. Alias the --ui-button-* family (owned in button.css) so a select's options and a menu's ghost items read as one; retune a --ui-button-* token and both follow. The ghost look is base button minus the surface fill + border, so only --ui-option-background/--ui-option-border diverge. Radius bridges to the shared --ui-menu-button-radius. */ --ui-option-font-family: var(--ui-button-font-family); --ui-option-font-size: var(--ui-button-font-size); --ui-option-font-weight: var(--ui-button-font-weight); --ui-option-line-height: var(--ui-button-line-height); --ui-option-gap: var(--ui-button-gap); --ui-option-padding: var(--ui-button-padding); --ui-option-height: var(--ui-button-height); --ui-option-icon-size: var(--ui-button-icon-size); --ui-option-radius: var(--ui-menu-button-radius); --ui-option-shadow: none; --ui-option-background: transparent; --ui-option-background--hover: var(--ui-button-background--hover); --ui-option-background--active: var(--ui-button-background--active); --ui-option-foreground: var(--ui-button-foreground); --ui-option-foreground--hover: var(--ui-button-foreground--hover); --ui-option-foreground--active: var(--ui-button-foreground--active); --ui-option-border: 1px solid transparent; --ui-option-border--hover: 1px solid transparent; --ui-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 */ --ui-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"); --ui-option-checkmark-size: var(--ui-field-icon-size); --ui-option-checkmark-color: currentColor; }}@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(.ui-select), :where(.ui-select)::picker(select) { appearance: base-select; } :where(.ui-select)::picker(select) { transform-origin: var(--ui-popover-origin); opacity: var(--ui-popover-opacity--start); backdrop-filter: var(--ui-popover-backdrop-filter); transform: scale(var(--ui-popover-scale--start)) translate3d(var(--ui-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(.ui-select:open)::picker(select) { opacity: var(--ui-popover-opacity--end); transform: scale(var(--ui-popover-scale--end)) translate3d(var(--ui-popover-translate--end)); display: flex; } @starting-style { :where(.ui-select:open)::picker(select) { opacity: var(--ui-popover-opacity--start); transform: scale(var(--ui-popover-scale--start)) translate3d(var(--ui-popover-translate--start)); } } :where(option)::checkmark { content: ""; inline-size: var(--ui-option-checkmark-size); block-size: var(--ui-option-checkmark-size); background-color: var(--ui-option-checkmark-color); mask: var(--ui-option-checkmark-mask) center / contain no-repeat; } :where(.ui-select)::picker-icon { content: ""; inline-size: var(--ui-select-picker-icon-size); block-size: var(--ui-select-picker-icon-size); background-color: var(--ui-select-picker-icon-color); mask: var(--ui-select-picker-icon-mask) center / contain no-repeat; }}@layer zazz.components { /* =========================================================================== SELECT: .ui-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 --ui-field-* tokens defined in fields.css. =========================================================================== */ .ui-select { display: flex; align-items: center; justify-content: space-between; gap: var(--ui-select-option-gap); inline-size: var(--ui-select-inline-size); min-inline-size: var(--ui-select-min-inline-size); block-size: var(--ui-field-height); padding-inline: var(--ui-select-padding-inline-start) var(--ui-select-padding-inline-end); font-size: var(--ui-select-font-size); line-height: var(--ui-select-line-height); text-align: start; color: var(--ui-field-foreground); background-color: var(--ui-field-background); background-image: none; border: var(--ui-select-border); border-radius: var(--ui-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; --_ring: color-mix(in oklch, var(--ui-field-ring-color) var(--ring-opacity), transparent); 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, var(--ring)); outline: var(--outline-width) var(--outline-style) transparent; outline-offset: var(--outline-offset); transition: var(--default-transition); } .ui-select:hover { border-color: var(--ui-field-border--hover); } .ui-select:focus-visible, .ui-select:open { border-color: var(--ui-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 */ .ui-select > button { block-size: 100%; display: flex; flex: 1; align-items: center; gap: var(--ui-field-gap); min-inline-size: 0; overflow: hidden; } .ui-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 */ .ui-select > button > svg, .ui-select selectedcontent > svg { flex-shrink: 0; inline-size: var(--ui-field-icon-size); block-size: var(--ui-field-icon-size); color: var(--muted-foreground); } .ui-select:open { background-color: var(--ui-field-background--focus); } .ui-select:open::picker-icon { transform: rotate(-180deg); } /* Dropdown panel: the shared [popover] surface (--ui-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, where the panel sizes to its content instead of stretching. */ .ui-select::picker(select) { flex-direction: column; gap: var(--step-px); inline-size: max-content; max-inline-size: var(--ui-popover-max-inline-size); padding: var(--ui-popover-padding); margin-block: var(--ui-popover-margin-block); overflow: clip; background-color: var(--popover); color: var(--popover-foreground); border: 1px solid var(--ui-popover-border); border-radius: var(--ui-popover-radius); box-shadow: var(--ui-popover-shadow); } @supports (inline-size: anchor-size(width)) { .ui-select::picker(select) { min-inline-size: anchor-size(width); } } /* =========================================================================== PICKER PLACEMENT: the shared matrix in popover.css owns data-side x data-align (it includes .ui-select, and custom properties set on the select inherit into ::picker(select)). Only the application of those tokens lives here, because position-area is gated on anchor support. =========================================================================== */ /* 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) { .ui-select::picker(select) { position-area: var(--ui-popover-position-area); position-try-fallbacks: var(--ui-popover-position-try-fallbacks); margin-inline: var(--ui-popover-margin-inline); } } /* Options render as ghost buttons: the --ui-option-* family (set in @layer variables) aliases the --ui-button-* ghost values, so a select's menu items and a menu's ghost items read identically. Like .ui-button, sizing rides on min-block-size + padding-inline (no block padding) so every row is uniform. */ .ui-select option { display: flex; align-items: center; gap: var(--ui-option-gap); min-block-size: var(--ui-option-height); padding-inline: var(--ui-option-padding); font-family: var(--ui-option-font-family); font-size: var(--ui-option-font-size); font-weight: var(--ui-option-font-weight); line-height: var(--ui-option-line-height); color: var(--ui-option-foreground); background-color: var(--ui-option-background); border: var(--ui-option-border); border-radius: var(--ui-option-radius); box-shadow: var(--ui-option-shadow); cursor: pointer; transition: var(--default-transition); } .ui-select option:hover { color: var(--ui-option-foreground--hover); background-color: var(--ui-option-background--hover); border: var(--ui-option-border--hover); } .ui-select option:checked { color: var(--ui-option-foreground--active); background-color: var(--ui-option-background--active); border: var(--ui-option-border--active); } .ui-select option:disabled { --ui-option-foreground: var(--muted-foreground); cursor: default; opacity: 0.5; } .ui-select option > svg { flex-shrink: 0; inline-size: var(--ui-option-icon-size); block-size: var(--ui-option-icon-size); color: var(--muted-foreground); } .ui-select option::checkmark { order: 1; margin-inline-start: auto; color: currentcolor; } /* =========================================================================== MULTISELECT: ui-multiselect (JS-projected dropdown over <select multiple>) - The script hides the real select and stamps a .ui-select-classed trigger + a popover of checkbox rows (see multiselect.ts). Rows are ghost options (--ui-option-*) with the shared checkmark, so single- and multi-select pickers read identically. - Without JS the native multi listbox renders; it gets minimal field-family dress below rather than the single-select trigger treatment. =========================================================================== */ :where(ui-multiselect) { anchor-scope: --ui-multiselect-trigger; display: block; inline-size: var(--ui-select-inline-size); min-inline-size: var(--ui-select-min-inline-size); } /* Enhanced: the real select stays in the DOM as the form value carrier */ select[multiple][data-multiselect-enhanced] { display: none; } /* No-JS baseline: the native listbox wears the field surface, sized by rows */ ui-multiselect select[multiple].ui-select:not([data-multiselect-enhanced]) { display: block; block-size: auto; padding-block: var(--step-1); } [data-slot~="multiselect-trigger"] { anchor-name: --ui-multiselect-trigger; } [data-slot~="multiselect-label"] { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* Chevron: the ::picker-icon treatment, rebuilt for a plain button */ [data-slot~="multiselect-icon"] { flex-shrink: 0; inline-size: var(--ui-select-picker-icon-size); block-size: var(--ui-select-picker-icon-size); background-color: var(--ui-select-picker-icon-color); mask: var(--ui-select-picker-icon-mask) center / contain no-repeat; transition: var(--default-transition); } [data-slot~="multiselect-trigger"][aria-expanded="true"] [data-slot~="multiselect-icon"] { transform: rotate(-180deg); } [data-slot~="multiselect-panel"] { position-anchor: --ui-multiselect-trigger; /* Layout stays ungated so the close fade keeps it; only display is gated */ flex-direction: column; gap: var(--step-px); box-shadow: var(--ui-menu-shadow); } /* Only display is gated; see popover.css: closed popovers stay display: none */ [data-slot~="multiselect-panel"]:where(:popover-open, .\:popover-open) { display: flex; } @supports (inline-size: anchor-size(width)) { [data-slot~="multiselect-panel"] { min-inline-size: anchor-size(width); } } /* Rows are ghost options; the checkbox is real but visually replaced by the shared checkmark glyph */ [data-slot~="multiselect-option"] { display: flex; align-items: center; gap: var(--ui-option-gap); min-block-size: var(--ui-option-height); padding-inline: var(--ui-option-padding); font-family: var(--ui-option-font-family); font-size: var(--ui-option-font-size); font-weight: var(--ui-option-font-weight); line-height: var(--ui-option-line-height); color: var(--ui-option-foreground); background-color: var(--ui-option-background); border: var(--ui-option-border); border-radius: var(--ui-option-radius); cursor: pointer; transition: var(--default-transition); } [data-slot~="multiselect-option"]:hover { color: var(--ui-option-foreground--hover); background-color: var(--ui-option-background--hover); } [data-slot~="multiselect-option"]:has(:checked) { color: var(--ui-option-foreground--active); background-color: var(--ui-option-background--active); } [data-slot~="multiselect-option"]:has(:disabled) { color: var(--muted-foreground); cursor: default; opacity: 0.5; } /* The checkbox stays focusable and announced but invisible; keyboard focus surfaces on the row via :focus-within */ [data-slot~="multiselect-option"] input[type="checkbox"] { position: absolute; inline-size: 1px; block-size: 1px; overflow: clip; clip-path: inset(50%); white-space: nowrap; } [data-slot~="multiselect-option"]:focus-within { background-color: var(--ui-option-background--hover); outline: var(--outline-width) var(--outline-style) var(--ring); outline-offset: calc(var(--outline-offset) * -1); } [data-slot~="multiselect-option"]:has(:checked)::after { content: ""; order: 1; margin-inline-start: auto; inline-size: var(--ui-option-checkmark-size); block-size: var(--ui-option-checkmark-size); background-color: var(--ui-option-checkmark-color); mask: var(--ui-option-checkmark-mask) center / contain no-repeat; }}Alignment
<div class="flex gap-sm items-start"> <div class="ui-field"> <label data-slot="field-label" for="f-align-start">Start</label> <select class="ui-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="ui-field"> <label data-slot="field-label" for="f-align-center">Center</label> <select class="ui-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="ui-field"> <label data-slot="field-label" for="f-align-end">End</label> <select class="ui-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 (.ui-select on customizable <select>) * * @layer variables, reset, components * @requires layers.css, _variables.css, fields.css, _reset.css, popover.css (--ui-popover-*), button.css (--ui-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: placement tokens come from * popover.css's shared matrix (@supports gated) * @uses backdrop-filter: picker surface blur (shared with [popover]) * @uses Degrades to native OS dropdown where base-select is unsupported * @uses ui-multiselect (multiselect.ts): JS-projected dropdown for * <select multiple>; base-select has no stable multi-select yet, so * this is a recorded exception like base-select itself. Degrades to * the native multi listbox without JS; retire the enhancement when * base-select-for-multiple ships stable. * @tokens --ui-select-* (@layer variables); picker panel reuses --ui-popover-* (owned in popover.css) * @consumedby autocomplete.css, combobox.css, command.css (--ui-option-*, --ui-select-picker-icon-*) */@layer variables { :root { /* Box treatment: alias the shared --ui-field-* family (owned in fields.css) so a select, a text input, and a textarea read as one family. Retune a --ui-field-* token and every control follows; remap a --ui-select-* token to diverge just the select. */ --ui-select-inline-size: 100%; --ui-select-min-inline-size: 16ch; --ui-select-padding-inline-start: var(--ui-field-padding); /* Trailing space differs from .ui-input: room for the picker icon */ --ui-select-padding-inline-end: var(--step-2); --ui-select-font-size: var(--ui-field-font-size); --ui-select-line-height: var(--ui-field-line-height); --ui-select-border: 1px solid var(--ui-field-border); --ui-select-border-radius: var(--ui-field-radius); --ui-select-option-gap: var(--ui-field-option-gap); /* Picker icon (trigger chevron) ** @see https://www.svgbackgrounds.com/tools/svg-to-css/ ** Set to Legacy / URL Wrapper to update icon */ --ui-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"); --ui-select-picker-icon-size: var(--ui-field-icon-size); --ui-select-picker-icon-color: currentColor; /* Option (menu item): a ghost button rendered as a native <option>. Alias the --ui-button-* family (owned in button.css) so a select's options and a menu's ghost items read as one; retune a --ui-button-* token and both follow. The ghost look is base button minus the surface fill + border, so only --ui-option-background/--ui-option-border diverge. Radius bridges to the shared --ui-menu-button-radius. */ --ui-option-font-family: var(--ui-button-font-family); --ui-option-font-size: var(--ui-button-font-size); --ui-option-font-weight: var(--ui-button-font-weight); --ui-option-line-height: var(--ui-button-line-height); --ui-option-gap: var(--ui-button-gap); --ui-option-padding: var(--ui-button-padding); --ui-option-height: var(--ui-button-height); --ui-option-icon-size: var(--ui-button-icon-size); --ui-option-radius: var(--ui-menu-button-radius); --ui-option-shadow: none; --ui-option-background: transparent; --ui-option-background--hover: var(--ui-button-background--hover); --ui-option-background--active: var(--ui-button-background--active); --ui-option-foreground: var(--ui-button-foreground); --ui-option-foreground--hover: var(--ui-button-foreground--hover); --ui-option-foreground--active: var(--ui-button-foreground--active); --ui-option-border: 1px solid transparent; --ui-option-border--hover: 1px solid transparent; --ui-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 */ --ui-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"); --ui-option-checkmark-size: var(--ui-field-icon-size); --ui-option-checkmark-color: currentColor; }}@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(.ui-select), :where(.ui-select)::picker(select) { appearance: base-select; } :where(.ui-select)::picker(select) { transform-origin: var(--ui-popover-origin); opacity: var(--ui-popover-opacity--start); backdrop-filter: var(--ui-popover-backdrop-filter); transform: scale(var(--ui-popover-scale--start)) translate3d(var(--ui-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(.ui-select:open)::picker(select) { opacity: var(--ui-popover-opacity--end); transform: scale(var(--ui-popover-scale--end)) translate3d(var(--ui-popover-translate--end)); display: flex; } @starting-style { :where(.ui-select:open)::picker(select) { opacity: var(--ui-popover-opacity--start); transform: scale(var(--ui-popover-scale--start)) translate3d(var(--ui-popover-translate--start)); } } :where(option)::checkmark { content: ""; inline-size: var(--ui-option-checkmark-size); block-size: var(--ui-option-checkmark-size); background-color: var(--ui-option-checkmark-color); mask: var(--ui-option-checkmark-mask) center / contain no-repeat; } :where(.ui-select)::picker-icon { content: ""; inline-size: var(--ui-select-picker-icon-size); block-size: var(--ui-select-picker-icon-size); background-color: var(--ui-select-picker-icon-color); mask: var(--ui-select-picker-icon-mask) center / contain no-repeat; }}@layer zazz.components { /* =========================================================================== SELECT: .ui-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 --ui-field-* tokens defined in fields.css. =========================================================================== */ .ui-select { display: flex; align-items: center; justify-content: space-between; gap: var(--ui-select-option-gap); inline-size: var(--ui-select-inline-size); min-inline-size: var(--ui-select-min-inline-size); block-size: var(--ui-field-height); padding-inline: var(--ui-select-padding-inline-start) var(--ui-select-padding-inline-end); font-size: var(--ui-select-font-size); line-height: var(--ui-select-line-height); text-align: start; color: var(--ui-field-foreground); background-color: var(--ui-field-background); background-image: none; border: var(--ui-select-border); border-radius: var(--ui-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; --_ring: color-mix(in oklch, var(--ui-field-ring-color) var(--ring-opacity), transparent); 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, var(--ring)); outline: var(--outline-width) var(--outline-style) transparent; outline-offset: var(--outline-offset); transition: var(--default-transition); } .ui-select:hover { border-color: var(--ui-field-border--hover); } .ui-select:focus-visible, .ui-select:open { border-color: var(--ui-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 */ .ui-select > button { block-size: 100%; display: flex; flex: 1; align-items: center; gap: var(--ui-field-gap); min-inline-size: 0; overflow: hidden; } .ui-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 */ .ui-select > button > svg, .ui-select selectedcontent > svg { flex-shrink: 0; inline-size: var(--ui-field-icon-size); block-size: var(--ui-field-icon-size); color: var(--muted-foreground); } .ui-select:open { background-color: var(--ui-field-background--focus); } .ui-select:open::picker-icon { transform: rotate(-180deg); } /* Dropdown panel: the shared [popover] surface (--ui-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, where the panel sizes to its content instead of stretching. */ .ui-select::picker(select) { flex-direction: column; gap: var(--step-px); inline-size: max-content; max-inline-size: var(--ui-popover-max-inline-size); padding: var(--ui-popover-padding); margin-block: var(--ui-popover-margin-block); overflow: clip; background-color: var(--popover); color: var(--popover-foreground); border: 1px solid var(--ui-popover-border); border-radius: var(--ui-popover-radius); box-shadow: var(--ui-popover-shadow); } @supports (inline-size: anchor-size(width)) { .ui-select::picker(select) { min-inline-size: anchor-size(width); } } /* =========================================================================== PICKER PLACEMENT: the shared matrix in popover.css owns data-side x data-align (it includes .ui-select, and custom properties set on the select inherit into ::picker(select)). Only the application of those tokens lives here, because position-area is gated on anchor support. =========================================================================== */ /* 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) { .ui-select::picker(select) { position-area: var(--ui-popover-position-area); position-try-fallbacks: var(--ui-popover-position-try-fallbacks); margin-inline: var(--ui-popover-margin-inline); } } /* Options render as ghost buttons: the --ui-option-* family (set in @layer variables) aliases the --ui-button-* ghost values, so a select's menu items and a menu's ghost items read identically. Like .ui-button, sizing rides on min-block-size + padding-inline (no block padding) so every row is uniform. */ .ui-select option { display: flex; align-items: center; gap: var(--ui-option-gap); min-block-size: var(--ui-option-height); padding-inline: var(--ui-option-padding); font-family: var(--ui-option-font-family); font-size: var(--ui-option-font-size); font-weight: var(--ui-option-font-weight); line-height: var(--ui-option-line-height); color: var(--ui-option-foreground); background-color: var(--ui-option-background); border: var(--ui-option-border); border-radius: var(--ui-option-radius); box-shadow: var(--ui-option-shadow); cursor: pointer; transition: var(--default-transition); } .ui-select option:hover { color: var(--ui-option-foreground--hover); background-color: var(--ui-option-background--hover); border: var(--ui-option-border--hover); } .ui-select option:checked { color: var(--ui-option-foreground--active); background-color: var(--ui-option-background--active); border: var(--ui-option-border--active); } .ui-select option:disabled { --ui-option-foreground: var(--muted-foreground); cursor: default; opacity: 0.5; } .ui-select option > svg { flex-shrink: 0; inline-size: var(--ui-option-icon-size); block-size: var(--ui-option-icon-size); color: var(--muted-foreground); } .ui-select option::checkmark { order: 1; margin-inline-start: auto; color: currentcolor; } /* =========================================================================== MULTISELECT: ui-multiselect (JS-projected dropdown over <select multiple>) - The script hides the real select and stamps a .ui-select-classed trigger + a popover of checkbox rows (see multiselect.ts). Rows are ghost options (--ui-option-*) with the shared checkmark, so single- and multi-select pickers read identically. - Without JS the native multi listbox renders; it gets minimal field-family dress below rather than the single-select trigger treatment. =========================================================================== */ :where(ui-multiselect) { anchor-scope: --ui-multiselect-trigger; display: block; inline-size: var(--ui-select-inline-size); min-inline-size: var(--ui-select-min-inline-size); } /* Enhanced: the real select stays in the DOM as the form value carrier */ select[multiple][data-multiselect-enhanced] { display: none; } /* No-JS baseline: the native listbox wears the field surface, sized by rows */ ui-multiselect select[multiple].ui-select:not([data-multiselect-enhanced]) { display: block; block-size: auto; padding-block: var(--step-1); } [data-slot~="multiselect-trigger"] { anchor-name: --ui-multiselect-trigger; } [data-slot~="multiselect-label"] { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* Chevron: the ::picker-icon treatment, rebuilt for a plain button */ [data-slot~="multiselect-icon"] { flex-shrink: 0; inline-size: var(--ui-select-picker-icon-size); block-size: var(--ui-select-picker-icon-size); background-color: var(--ui-select-picker-icon-color); mask: var(--ui-select-picker-icon-mask) center / contain no-repeat; transition: var(--default-transition); } [data-slot~="multiselect-trigger"][aria-expanded="true"] [data-slot~="multiselect-icon"] { transform: rotate(-180deg); } [data-slot~="multiselect-panel"] { position-anchor: --ui-multiselect-trigger; /* Layout stays ungated so the close fade keeps it; only display is gated */ flex-direction: column; gap: var(--step-px); box-shadow: var(--ui-menu-shadow); } /* Only display is gated; see popover.css: closed popovers stay display: none */ [data-slot~="multiselect-panel"]:where(:popover-open, .\:popover-open) { display: flex; } @supports (inline-size: anchor-size(width)) { [data-slot~="multiselect-panel"] { min-inline-size: anchor-size(width); } } /* Rows are ghost options; the checkbox is real but visually replaced by the shared checkmark glyph */ [data-slot~="multiselect-option"] { display: flex; align-items: center; gap: var(--ui-option-gap); min-block-size: var(--ui-option-height); padding-inline: var(--ui-option-padding); font-family: var(--ui-option-font-family); font-size: var(--ui-option-font-size); font-weight: var(--ui-option-font-weight); line-height: var(--ui-option-line-height); color: var(--ui-option-foreground); background-color: var(--ui-option-background); border: var(--ui-option-border); border-radius: var(--ui-option-radius); cursor: pointer; transition: var(--default-transition); } [data-slot~="multiselect-option"]:hover { color: var(--ui-option-foreground--hover); background-color: var(--ui-option-background--hover); } [data-slot~="multiselect-option"]:has(:checked) { color: var(--ui-option-foreground--active); background-color: var(--ui-option-background--active); } [data-slot~="multiselect-option"]:has(:disabled) { color: var(--muted-foreground); cursor: default; opacity: 0.5; } /* The checkbox stays focusable and announced but invisible; keyboard focus surfaces on the row via :focus-within */ [data-slot~="multiselect-option"] input[type="checkbox"] { position: absolute; inline-size: 1px; block-size: 1px; overflow: clip; clip-path: inset(50%); white-space: nowrap; } [data-slot~="multiselect-option"]:focus-within { background-color: var(--ui-option-background--hover); outline: var(--outline-width) var(--outline-style) var(--ring); outline-offset: calc(var(--outline-offset) * -1); } [data-slot~="multiselect-option"]:has(:checked)::after { content: ""; order: 1; margin-inline-start: auto; inline-size: var(--ui-option-checkmark-size); block-size: var(--ui-option-checkmark-size); background-color: var(--ui-option-checkmark-color); mask: var(--ui-option-checkmark-mask) center / contain no-repeat; }}Multiple
appearance: base-select does not cover <select multiple> in any stable engine yet, so <ui-multiselect> progressively enhances a multi-select element into the same dropdown styling. The select is hidden but remains the form value carrier, while a stamped trigger and popover of checkbox rows (with the shared option checkmark) sync values both ways. The trigger displays First selection (+N more). Without JavaScript, the native multi listbox renders and remains fully functional.
<div class="ui-field max-w-xl"> <label data-slot="field-label" for="f-tags">Tags</label> <ui-multiselect data-placeholder="Select tags" data-side="bottom" data-align="start"> <select class="ui-select" id="f-tags" name="tags" multiple> <option value="design" selected>Design</option> <option value="engineering" selected>Engineering</option> <option value="research">Research</option> <option value="marketing">Marketing</option> <option value="ops" disabled>Operations</option> </select> </ui-multiselect></div>// primitives/select/multiselect.js"use strict";/** * @fileoverview `<ui-multiselect>`: a multi-select styled as a dropdown. * @description Light-DOM custom element that progressively enhances a real * `<select multiple class="ui-select">`. `appearance: base-select` does not * yet apply to multi-selects in any stable engine, so the script hides the * select (which remains the form value carrier and source of truth) and * stamps a `.ui-select`-look trigger button plus an anchored popover of * checkbox rows projected from the options. Checkbox changes write back to * `option.selected` and re-dispatch `change` on the select; external writes * and form resets flow the other way. Without JavaScript the native * multi-select listbox renders: fully functional and accessible. * * The trigger reads `<first selection> (+N more)`. When base-select grows * stable multi-select support this enhancement can retire (see the CSS * header note in select.css). * * Attributes on `<ui-multiselect>`: * - `data-placeholder`: trigger text when nothing is selected. * - `data-label-more`: overflow template, default `"(+{n} more)"`. * - `data-side` / `data-align`: forwarded to the stamped panel (popover * placement matrix). * * Stamped parts: `multiselect-trigger` (a `.ui-select`-classed button), * `multiselect-icon` (chevron), `multiselect-panel` (`[popover]`), * `multiselect-option` (label + checkbox per option). */import { ZazzElement, defineZazzElement } from "../../base/zazz-element.js";import { effect, state } from "../../base/signals.js";// --- Pure derivations (exported for unit tests only) ---/** * @description Formats the trigger label from the selected option labels. * * @param labels - Labels of the selected options, in DOM order. * @param placeholder - Text for an empty selection. * @param moreTemplate - Overflow template; `{n}` is the remaining count. * @returns The trigger text. */function resolveTriggerLabel(labels, placeholder, moreTemplate) { if (labels.length === 0) return placeholder; if (labels.length === 1) return labels[0]; return `${labels[0]} ${moreTemplate.replace("{n}", String(labels.length - 1))}`;}// --- Element ---let multiselectIdCounter = 0;class UiMultiselect extends ZazzElement { #stamped = []; #select = null; setup(signal) { const select = this.querySelector("select[multiple]"); if (!(select instanceof HTMLSelectElement)) return; this.#select = select; const placeholder = this.getAttribute("data-placeholder") ?? "Select…"; const moreTemplate = this.getAttribute("data-label-more") ?? "(+{n} more)"; const { trigger, label, panel, checkboxes } = this.#stamp(select); select.setAttribute("data-multiselect-enhanced", ""); const selectedLabels = () => Array.from(select.selectedOptions).map((option) => option.label); const selection = state(selectedLabels()); // Checkbox → option: the select stays the source of truth checkboxes.forEach((checkbox, index) => { checkbox.addEventListener("change", () => { const option = select.options[index]; if (option) option.selected = checkbox.checked; selection.set(selectedLabels()); select.dispatchEvent(new Event("change", { bubbles: true })); }, { signal }); }); // External writes and form resets → re-read (reset applies after the event) select.addEventListener("change", () => selection.set(selectedLabels()), { signal }); select.form?.addEventListener("reset", () => queueMicrotask(() => selection.set(selectedLabels())), { signal }); // The popover polyfill doesn't reflect expanded state: mirror it ourselves panel.addEventListener("toggle", (event) => { trigger.setAttribute("aria-expanded", String(event.newState === "open")); }, { signal }); // Output adapter: trigger label + row checked states together effect(() => { selection.get(); label.textContent = resolveTriggerLabel(selectedLabels(), placeholder, moreTemplate); checkboxes.forEach((checkbox, index) => { const option = select.options[index]; if (option && checkbox.checked !== option.selected) checkbox.checked = option.selected; }); }, { signal }); } teardown() { for (const node of this.#stamped) node.remove(); this.#stamped = []; this.#select?.removeAttribute("data-multiselect-enhanced"); this.#select = null; } /** * @description Builds the trigger button and checkbox panel from the * select's options and inserts them after it. * * @param select - The enhanced select. * @returns The stamped parts and the checkbox list (option order). * @private */ #stamp(select) { const panelId = `ui-multiselect-panel-${++multiselectIdCounter}`; const trigger = document.createElement("button"); trigger.type = "button"; trigger.className = "ui-select"; trigger.setAttribute("data-slot", "multiselect-trigger"); trigger.setAttribute("popovertarget", panelId); trigger.setAttribute("aria-expanded", "false"); const label = document.createElement("span"); label.setAttribute("data-slot", "multiselect-label"); const icon = document.createElement("span"); icon.setAttribute("data-slot", "multiselect-icon"); icon.setAttribute("aria-hidden", "true"); trigger.append(label, icon); const panel = document.createElement("div"); panel.id = panelId; panel.setAttribute("data-slot", "multiselect-panel"); panel.setAttribute("popover", "auto"); const side = this.getAttribute("data-side"); const align = this.getAttribute("data-align"); if (side) panel.setAttribute("data-side", side); if (align) panel.setAttribute("data-align", align); const checkboxes = []; for (const option of Array.from(select.options)) { const row = document.createElement("label"); row.setAttribute("data-slot", "multiselect-option"); const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.checked = option.selected; checkbox.disabled = option.disabled; row.append(checkbox, document.createTextNode(option.label)); panel.append(row); checkboxes.push(checkbox); } select.after(trigger, panel); this.#stamped = [trigger, panel]; return { trigger, label, panel, checkboxes }; }}defineZazzElement("ui-multiselect", UiMultiselect);export { UiMultiselect, resolveTriggerLabel };/** * select.css: Select (.ui-select on customizable <select>) * * @layer variables, reset, components * @requires layers.css, _variables.css, fields.css, _reset.css, popover.css (--ui-popover-*), button.css (--ui-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: placement tokens come from * popover.css's shared matrix (@supports gated) * @uses backdrop-filter: picker surface blur (shared with [popover]) * @uses Degrades to native OS dropdown where base-select is unsupported * @uses ui-multiselect (multiselect.ts): JS-projected dropdown for * <select multiple>; base-select has no stable multi-select yet, so * this is a recorded exception like base-select itself. Degrades to * the native multi listbox without JS; retire the enhancement when * base-select-for-multiple ships stable. * @tokens --ui-select-* (@layer variables); picker panel reuses --ui-popover-* (owned in popover.css) * @consumedby autocomplete.css, combobox.css, command.css (--ui-option-*, --ui-select-picker-icon-*) */@layer variables { :root { /* Box treatment: alias the shared --ui-field-* family (owned in fields.css) so a select, a text input, and a textarea read as one family. Retune a --ui-field-* token and every control follows; remap a --ui-select-* token to diverge just the select. */ --ui-select-inline-size: 100%; --ui-select-min-inline-size: 16ch; --ui-select-padding-inline-start: var(--ui-field-padding); /* Trailing space differs from .ui-input: room for the picker icon */ --ui-select-padding-inline-end: var(--step-2); --ui-select-font-size: var(--ui-field-font-size); --ui-select-line-height: var(--ui-field-line-height); --ui-select-border: 1px solid var(--ui-field-border); --ui-select-border-radius: var(--ui-field-radius); --ui-select-option-gap: var(--ui-field-option-gap); /* Picker icon (trigger chevron) ** @see https://www.svgbackgrounds.com/tools/svg-to-css/ ** Set to Legacy / URL Wrapper to update icon */ --ui-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"); --ui-select-picker-icon-size: var(--ui-field-icon-size); --ui-select-picker-icon-color: currentColor; /* Option (menu item): a ghost button rendered as a native <option>. Alias the --ui-button-* family (owned in button.css) so a select's options and a menu's ghost items read as one; retune a --ui-button-* token and both follow. The ghost look is base button minus the surface fill + border, so only --ui-option-background/--ui-option-border diverge. Radius bridges to the shared --ui-menu-button-radius. */ --ui-option-font-family: var(--ui-button-font-family); --ui-option-font-size: var(--ui-button-font-size); --ui-option-font-weight: var(--ui-button-font-weight); --ui-option-line-height: var(--ui-button-line-height); --ui-option-gap: var(--ui-button-gap); --ui-option-padding: var(--ui-button-padding); --ui-option-height: var(--ui-button-height); --ui-option-icon-size: var(--ui-button-icon-size); --ui-option-radius: var(--ui-menu-button-radius); --ui-option-shadow: none; --ui-option-background: transparent; --ui-option-background--hover: var(--ui-button-background--hover); --ui-option-background--active: var(--ui-button-background--active); --ui-option-foreground: var(--ui-button-foreground); --ui-option-foreground--hover: var(--ui-button-foreground--hover); --ui-option-foreground--active: var(--ui-button-foreground--active); --ui-option-border: 1px solid transparent; --ui-option-border--hover: 1px solid transparent; --ui-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 */ --ui-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"); --ui-option-checkmark-size: var(--ui-field-icon-size); --ui-option-checkmark-color: currentColor; }}@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(.ui-select), :where(.ui-select)::picker(select) { appearance: base-select; } :where(.ui-select)::picker(select) { transform-origin: var(--ui-popover-origin); opacity: var(--ui-popover-opacity--start); backdrop-filter: var(--ui-popover-backdrop-filter); transform: scale(var(--ui-popover-scale--start)) translate3d(var(--ui-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(.ui-select:open)::picker(select) { opacity: var(--ui-popover-opacity--end); transform: scale(var(--ui-popover-scale--end)) translate3d(var(--ui-popover-translate--end)); display: flex; } @starting-style { :where(.ui-select:open)::picker(select) { opacity: var(--ui-popover-opacity--start); transform: scale(var(--ui-popover-scale--start)) translate3d(var(--ui-popover-translate--start)); } } :where(option)::checkmark { content: ""; inline-size: var(--ui-option-checkmark-size); block-size: var(--ui-option-checkmark-size); background-color: var(--ui-option-checkmark-color); mask: var(--ui-option-checkmark-mask) center / contain no-repeat; } :where(.ui-select)::picker-icon { content: ""; inline-size: var(--ui-select-picker-icon-size); block-size: var(--ui-select-picker-icon-size); background-color: var(--ui-select-picker-icon-color); mask: var(--ui-select-picker-icon-mask) center / contain no-repeat; }}@layer zazz.components { /* =========================================================================== SELECT: .ui-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 --ui-field-* tokens defined in fields.css. =========================================================================== */ .ui-select { display: flex; align-items: center; justify-content: space-between; gap: var(--ui-select-option-gap); inline-size: var(--ui-select-inline-size); min-inline-size: var(--ui-select-min-inline-size); block-size: var(--ui-field-height); padding-inline: var(--ui-select-padding-inline-start) var(--ui-select-padding-inline-end); font-size: var(--ui-select-font-size); line-height: var(--ui-select-line-height); text-align: start; color: var(--ui-field-foreground); background-color: var(--ui-field-background); background-image: none; border: var(--ui-select-border); border-radius: var(--ui-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; --_ring: color-mix(in oklch, var(--ui-field-ring-color) var(--ring-opacity), transparent); 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, var(--ring)); outline: var(--outline-width) var(--outline-style) transparent; outline-offset: var(--outline-offset); transition: var(--default-transition); } .ui-select:hover { border-color: var(--ui-field-border--hover); } .ui-select:focus-visible, .ui-select:open { border-color: var(--ui-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 */ .ui-select > button { block-size: 100%; display: flex; flex: 1; align-items: center; gap: var(--ui-field-gap); min-inline-size: 0; overflow: hidden; } .ui-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 */ .ui-select > button > svg, .ui-select selectedcontent > svg { flex-shrink: 0; inline-size: var(--ui-field-icon-size); block-size: var(--ui-field-icon-size); color: var(--muted-foreground); } .ui-select:open { background-color: var(--ui-field-background--focus); } .ui-select:open::picker-icon { transform: rotate(-180deg); } /* Dropdown panel: the shared [popover] surface (--ui-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, where the panel sizes to its content instead of stretching. */ .ui-select::picker(select) { flex-direction: column; gap: var(--step-px); inline-size: max-content; max-inline-size: var(--ui-popover-max-inline-size); padding: var(--ui-popover-padding); margin-block: var(--ui-popover-margin-block); overflow: clip; background-color: var(--popover); color: var(--popover-foreground); border: 1px solid var(--ui-popover-border); border-radius: var(--ui-popover-radius); box-shadow: var(--ui-popover-shadow); } @supports (inline-size: anchor-size(width)) { .ui-select::picker(select) { min-inline-size: anchor-size(width); } } /* =========================================================================== PICKER PLACEMENT: the shared matrix in popover.css owns data-side x data-align (it includes .ui-select, and custom properties set on the select inherit into ::picker(select)). Only the application of those tokens lives here, because position-area is gated on anchor support. =========================================================================== */ /* 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) { .ui-select::picker(select) { position-area: var(--ui-popover-position-area); position-try-fallbacks: var(--ui-popover-position-try-fallbacks); margin-inline: var(--ui-popover-margin-inline); } } /* Options render as ghost buttons: the --ui-option-* family (set in @layer variables) aliases the --ui-button-* ghost values, so a select's menu items and a menu's ghost items read identically. Like .ui-button, sizing rides on min-block-size + padding-inline (no block padding) so every row is uniform. */ .ui-select option { display: flex; align-items: center; gap: var(--ui-option-gap); min-block-size: var(--ui-option-height); padding-inline: var(--ui-option-padding); font-family: var(--ui-option-font-family); font-size: var(--ui-option-font-size); font-weight: var(--ui-option-font-weight); line-height: var(--ui-option-line-height); color: var(--ui-option-foreground); background-color: var(--ui-option-background); border: var(--ui-option-border); border-radius: var(--ui-option-radius); box-shadow: var(--ui-option-shadow); cursor: pointer; transition: var(--default-transition); } .ui-select option:hover { color: var(--ui-option-foreground--hover); background-color: var(--ui-option-background--hover); border: var(--ui-option-border--hover); } .ui-select option:checked { color: var(--ui-option-foreground--active); background-color: var(--ui-option-background--active); border: var(--ui-option-border--active); } .ui-select option:disabled { --ui-option-foreground: var(--muted-foreground); cursor: default; opacity: 0.5; } .ui-select option > svg { flex-shrink: 0; inline-size: var(--ui-option-icon-size); block-size: var(--ui-option-icon-size); color: var(--muted-foreground); } .ui-select option::checkmark { order: 1; margin-inline-start: auto; color: currentcolor; } /* =========================================================================== MULTISELECT: ui-multiselect (JS-projected dropdown over <select multiple>) - The script hides the real select and stamps a .ui-select-classed trigger + a popover of checkbox rows (see multiselect.ts). Rows are ghost options (--ui-option-*) with the shared checkmark, so single- and multi-select pickers read identically. - Without JS the native multi listbox renders; it gets minimal field-family dress below rather than the single-select trigger treatment. =========================================================================== */ :where(ui-multiselect) { anchor-scope: --ui-multiselect-trigger; display: block; inline-size: var(--ui-select-inline-size); min-inline-size: var(--ui-select-min-inline-size); } /* Enhanced: the real select stays in the DOM as the form value carrier */ select[multiple][data-multiselect-enhanced] { display: none; } /* No-JS baseline: the native listbox wears the field surface, sized by rows */ ui-multiselect select[multiple].ui-select:not([data-multiselect-enhanced]) { display: block; block-size: auto; padding-block: var(--step-1); } [data-slot~="multiselect-trigger"] { anchor-name: --ui-multiselect-trigger; } [data-slot~="multiselect-label"] { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* Chevron: the ::picker-icon treatment, rebuilt for a plain button */ [data-slot~="multiselect-icon"] { flex-shrink: 0; inline-size: var(--ui-select-picker-icon-size); block-size: var(--ui-select-picker-icon-size); background-color: var(--ui-select-picker-icon-color); mask: var(--ui-select-picker-icon-mask) center / contain no-repeat; transition: var(--default-transition); } [data-slot~="multiselect-trigger"][aria-expanded="true"] [data-slot~="multiselect-icon"] { transform: rotate(-180deg); } [data-slot~="multiselect-panel"] { position-anchor: --ui-multiselect-trigger; /* Layout stays ungated so the close fade keeps it; only display is gated */ flex-direction: column; gap: var(--step-px); box-shadow: var(--ui-menu-shadow); } /* Only display is gated; see popover.css: closed popovers stay display: none */ [data-slot~="multiselect-panel"]:where(:popover-open, .\:popover-open) { display: flex; } @supports (inline-size: anchor-size(width)) { [data-slot~="multiselect-panel"] { min-inline-size: anchor-size(width); } } /* Rows are ghost options; the checkbox is real but visually replaced by the shared checkmark glyph */ [data-slot~="multiselect-option"] { display: flex; align-items: center; gap: var(--ui-option-gap); min-block-size: var(--ui-option-height); padding-inline: var(--ui-option-padding); font-family: var(--ui-option-font-family); font-size: var(--ui-option-font-size); font-weight: var(--ui-option-font-weight); line-height: var(--ui-option-line-height); color: var(--ui-option-foreground); background-color: var(--ui-option-background); border: var(--ui-option-border); border-radius: var(--ui-option-radius); cursor: pointer; transition: var(--default-transition); } [data-slot~="multiselect-option"]:hover { color: var(--ui-option-foreground--hover); background-color: var(--ui-option-background--hover); } [data-slot~="multiselect-option"]:has(:checked) { color: var(--ui-option-foreground--active); background-color: var(--ui-option-background--active); } [data-slot~="multiselect-option"]:has(:disabled) { color: var(--muted-foreground); cursor: default; opacity: 0.5; } /* The checkbox stays focusable and announced but invisible; keyboard focus surfaces on the row via :focus-within */ [data-slot~="multiselect-option"] input[type="checkbox"] { position: absolute; inline-size: 1px; block-size: 1px; overflow: clip; clip-path: inset(50%); white-space: nowrap; } [data-slot~="multiselect-option"]:focus-within { background-color: var(--ui-option-background--hover); outline: var(--outline-width) var(--outline-style) var(--ring); outline-offset: calc(var(--outline-offset) * -1); } [data-slot~="multiselect-option"]:has(:checked)::after { content: ""; order: 1; margin-inline-start: auto; inline-size: var(--ui-option-checkmark-size); block-size: var(--ui-option-checkmark-size); background-color: var(--ui-option-checkmark-color); mask: var(--ui-option-checkmark-mask) center / contain no-repeat; }}API
| Attribute | Target | Values |
|---|---|---|
data-side | .ui-select | top, bottom, left, right |
data-align | .ui-select | start, center, end |
data-placeholder | ui-multiselect | Trigger text when nothing is selected |
data-label-more | ui-multiselect | Overflow template, default (+{n} more) |
data-side / data-align | ui-multiselect | Forwarded to the stamped panel |