sveltelean Primitives
Versionv0.2.0 GitHub

Example

A fruit

    The bound value: empty

    <label id="fruit-label" for="fruit-native">Fruit</label>
    <div id="fruit" data-slean="select">
    	<select id="fruit-native" name="fruit" data-slean-part="native">
    		<option value="" hidden>Select a fruit</option>
    		<option value="apple">Apple</option>
    		<option value="banana">Banana</option>
    		<option value="cherry" disabled>Cherry</option>
    		<option value="grape">Grape</option>
    		<option value="mango">Mango</option>
    	</select>
    	<button
    		type="button"
    		role="combobox"
    		aria-haspopup="listbox"
    		aria-expanded="false"
    		aria-controls="fruit-list"
    		aria-labelledby="fruit-label"
    		data-slean-part="trigger"
    	>
    		<span data-slean-part="value"></span>
    		<span data-slean-part="placeholder">Select a fruit</span>
    	</button>
    	<button type="button" tabindex="-1" aria-label="Clear" data-slean-part="clear"></button>
    	<span data-slean-part="toggle" aria-hidden="true"></span>
    	<div popover="manual" data-slean-part="popup">
    		<ul id="fruit-list" role="listbox" aria-labelledby="fruit-label"></ul>
    	</div>
    </div>
    Click anywhere in the field, or focus it and press ArrowDown, Enter or a letter. The arrows skip Cherry (disabled), Enter chooses, Escape closes without a change. The clear button appears on hover once there is a value and returns to the placeholder.

    Why this implementation exists

    The native <select> has the value, form submission, autofill, disabled options and optgroups, and it is the right control without JavaScript. Its interface is not the same everywhere: the customizable select (appearance: base-select) is in Chrome and Safari 27, not in Firefox, and the platform menus cannot show a placeholder, a clear button or tags. ADR 0007 keeps the native select as the value and draws the field over it.

    The owned field is the control shell every form control shares (control.css). The popup listbox is empty in the markup: the behavior renders it from the native options each time it opens, so the options the application renders into the <select> are the only list there is, and the value is never mirrored. Focus stays on the trigger and the active option is announced through aria-activedescendant, the APG select-only combobox.

    The browser owns

    • the value, the options, disabled options, optgroups and form submission of the native <select>
    • the popup in the top layer through showPopover() and hidePopover()
    • placement under the field through CSS anchor positioning in the stylesheet
    • the combobox, listbox and option roles and the aria-activedescendant announcement
    • the native select itself when scripting is off

    Svelte Lean owns

    • the field: value, placeholder, clear button and chevron inside one control shell (control.css)
    • rendering the listbox from the native options and optgroups on every open
    • the keyboard of the APG select-only combobox: arrows, Home and End, PageUp and PageDown, type-ahead, Enter, Space, Escape, Tab
    • writing the native select and dispatching input and change; the cancelable slean:select event
    • tags with a remove button for a multiple select, and Backspace to drop the last one
    • the lazy controller: a WeakMap entry on the first interaction and one scoped document listener while open
    • development validation of the markup, select.css and the contract

    Usage

    Install @svelte-lean/primitives for the behavior and @svelte-lean/styles for the stylesheet. With @svelte-lean/vite the registration is injected for every static data-slean="select"; without it, import the register module once.

    npm install @svelte-lean/primitives
    +layout.svelte or any client module
    import '@svelte-lean/primitives/select/register';
    stylesheets
    import '@svelte-lean/styles/control.css';
    import '@svelte-lean/styles/select.css';

    Wrap a native <select data-slean-part="native"> in <div data-slean="select"> with a role="combobox" trigger (holding the value and placeholder parts), an optional clear button and chevron, and a popover="manual" popup with an empty role="listbox". A hidden first option is the placeholder.

    Bind the native select with bind:value and render the chosen label in the value part; the behavior writes the native select, dispatches input and change, and updates the label through its text node. After setting the value in script, dispatch change on the native select.

    For search inside the list use Combobox. A bare <select data-slean="select"> is the Tier 0 styled native select (see below).

    Anatomy

    PartElementdata-slean-partRequiredNotes
    root<div id="…" data-slean="select">–yesThe field. Options: data-slean-loop, data-slean-render="app"; styles: data-size="sm|lg", data-status="error|warning".
    native<select name="…">nativeyesThe options, the value, the form name, disabled and multiple. A hidden option is the placeholder. Shown only without scripting.
    trigger<button type="button" role="combobox" aria-haspopup="listbox" aria-controls="…">triggeryesHolds DOM focus and a name (aria-labelledby). A <div role="combobox" tabindex="0"> for a multiple select, whose tags contain buttons.
    value<span>valueyesInside the trigger: the chosen label (server-render it), or the tags of a multiple select.
    placeholder<span>placeholdernoAfter value; shown by the stylesheet while value is empty.
    clear<button type="button" tabindex="-1" aria-label="…">clearnoShown on hover or focus while there is a value; returns to the placeholder option.
    toggle<span aria-hidden="true">togglenoThe chevron, a mask from --slean-icon-chevron-down; turns while the popup is open.
    popup<div popover="manual">popupyesHolds an empty <ul role="listbox"> the trigger’s aria-controls names; the behavior fills it on open.
    emptyany element in the popupemptynoShown when the native select has no visible option.
    tag<span data-slean-value="…">tagnoMultiple: one per chosen option, rendered by the behavior, or by the application with data-slean-render="app".
    remove<button type="button" tabindex="-1" aria-label="…">removenoInside a tag; drops that option.

    Runtime profile

    Tier 2: a lazy scoped controller. Seven shared listeners (click, keydown, pointerdown, pointerover, focusout, change and toggle in the capture phase) serve every select on the page. A root gets a WeakMap entry on its first interaction and, while its popup is open, one document pointerdown listener that closes it on a press outside; focus leaving the root deletes the entry. The Vite plugin injects the register module for a select marker on a <div> and nothing for the same marker on a bare <select>.

    Accessibility contract

    • The trigger is a role="combobox" with aria-haspopup="listbox", aria-expanded (written), aria-controls naming the listbox and aria-labelledby naming the label; it keeps DOM focus and aria-activedescendant names the active option.
    • Options are role="option" with aria-selected on the chosen ones and aria-disabled for a disabled native option; an optgroup is a role="group" labelled by its label. A multiple select’s listbox has aria-multiselectable="true".
    • The clear button, the tag remove buttons and the chevron are out of the tab order; Backspace removes the last tag from the keyboard.
    • An error is aria-invalid="true" on the trigger with the message in aria-describedby; the native select has no validation bubble while it is hidden, so validate in the application.

    Keyboard

    KeyWhenResult
    ArrowDown/ArrowUp/Enter/Spacetrigger focused, popup closedOpens on the chosen option, else the first enabled one
    ArrowDown/ArrowUppopup openMoves the active option, skipping disabled ones; wraps unless data-slean-loop="false"
    Home/Endtrigger focusedOpens if needed and moves to the first or last enabled option
    PageUp/PageDownpopup openTen options up or down
    Enter/Spacepopup openChooses the active option; a single select closes, a multiple select toggles it and stays open
    letterstrigger focusedOpens if needed and moves to the matching option (type-ahead)
    Escape/Alt+ArrowUppopup openCloses without a change; when closed, Escape is left to ancestors
    Tabpopup openCloses without a change; focus moves natively
    Backspacemultiple, popup closedDrops the last tag

    Platform features

    FeatureBaselineOutside the target
    <select>, <option>, <optgroup>Widely availableNot applicable
    Popover API: popover, showPopover(), hidePopover(), :popover-openBaseline 2024The popup renders in place instead of in the top layer
    CSS anchor positioning: anchor-name, anchor-scope, position-area, anchor-size()Not Baseline: Chrome 125 (anchor-scope 131) and Safari 26The popover popup is centered in the top layer
    :has(), @media (scripting)Baseline 2023The clear button and the placeholder do not react to the value; without the scripting query both the field and the native select show

    Without JavaScript

    @media (scripting: none) in select.css hides the trigger, the clear button, the chevron and the popup and shows the native <select>, styled as the Tier 0 select below: the platform’s picker, keyboard and form submission. With scripting the native select is hidden and the owned field works on it.

    Server rendering

    Render the chosen option’s label in the value part, aria-expanded as false, an empty listbox and a closed popup. The register module is safe to import on the server.

    Before hydration

    Before the behavior loads, the field shows the server’s label and nothing opens. The registration attaches no per-root state, so the first interaction after hydration creates the controller and renders the options.

    Styling

    control.css draws the field: border, hover, focus halo, sizes (data-size), status (data-status), the clear and chevron icon masks, the popup surface, the option rows, the group labels and the check mark. select.css adds the value, the placeholder and the tags, hands the field to the native select without scripting, and styles the Tier 0 select. The tokens of control.css and select.css:

    TokenDefault (light)Applies to
    --slean-space-10.25remgap, padding-inline, margin-block, padding, scroll-padding-block, padding-block
    --slean-control-height-md2.25remmin-block-size
    --slean-space-30.75rempadding-inline, background-position, padding-inline-end
    --slean-space-20.5rempadding-inline, max-inline-size, gap, padding-block, padding-inline-start
    --slean-control-bordervar(--slean-border-strong)border
    --slean-radius-md0.625remborder-radius
    --slean-control-bgvar(--slean-surface)background, background-color
    --slean-fgvar(--slean-neutral-12)color
    --slean-text-sm0.875remfont-size
    --slean-leading1.5line-height
    --slean-duration-fast100mstransition
    --slean-easecubic-bezier(0.2, 0, 0, 1)transition
    --slean-control-border-hovervar(--slean-accent)border-color
    --slean-control-border-focusvar(--slean-accent)border-color
    --slean-control-ring0 0 0 3px color-mix(in oklch, var(--slean-accent) 22%, transparent)box-shadow
    --slean-dangeroklch(55% 0.2 25)border-color
    --slean-control-ring-danger0 0 0 3px color-mix(in oklch, var(--slean-danger) 22%, transparent)box-shadow
    --slean-warningoklch(76% 0.16 80)border-color
    --slean-control-ring-warning0 0 0 3px color-mix(in oklch, var(--slean-warning) 28%, transparent)box-shadow
    --slean-control-border-disabledvar(--slean-border)border-color
    --slean-control-bg-disabledvar(--slean-muted)background, background-color
    --slean-fg-mutedvar(--slean-neutral-11)color
    --slean-control-height-sm2remmin-block-size
    --slean-radius-sm0.375remborder-radius
    --slean-control-height-lg2.75remmin-block-size
    --slean-space-41rempadding-inline, padding-block, padding-inline-start
    --slean-text-md1remfont-size
    --slean-control-iconvar(--slean-fg-muted)color, background-image, background
    --slean-icon-chevron-downurl("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 6l4 4 4-4' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")mask
    --slean-duration-normal160mstransition
    --slean-icon-clearurl("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cdefs%3E%3Cmask id='m'%3E%3Crect width='16' height='16' fill='white'/%3E%3Cpath d='M6 6l4 4m0-4l-4 4' stroke='black' stroke-width='1.5' stroke-linecap='round'/%3E%3C/mask%3E%3C/defs%3E%3Ccircle cx='8' cy='8' r='6.5' mask='url(%23m)'/%3E%3C/svg%3E")mask-image
    --slean-bordervar(--slean-neutral-6)border
    --slean-popup-radiusvar(--slean-radius-md)border-radius
    --slean-surfaceoklch(100% 0 0)background
    --slean-popup-shadowvar(--slean-shadow-lg)box-shadow
    --slean-icon-checkurl("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.5l3 3 6-7' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")mask
    --slean-option-activevar(--slean-muted)background
    --slean-option-selectedvar(--slean-accent-soft)background
    --slean-option-selected-fgvar(--slean-accent-soft-fg)color, box-shadow
    --slean-font-weight-semibold600font-weight
    --slean-text-xs0.75remfont-size
    --slean-font-weight-medium500font-weight
    --slean-accentoklch(54% 0.19 258)color
    --slean-focus-ringvar(--slean-focus-ring-width) solid var(--slean-focus-ring-color)outline
    --slean-focus-ring-width2pxoutline-offset
    --slean-mutedvar(--slean-neutral-3)background
    --slean-icon-closeurl("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4.5 4.5l7 7m0-7l-7 7' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")mask
    --slean-muted-hovervar(--slean-neutral-4)background

    State selectors the stylesheet targets, all from the platform or ARIA: ::checkmark, ::picker(select), ::picker-icon, ::placeholder, :checked, :dir(rtl), :disabled, :empty, :focus-visible, :focus-within, :hover, :open, :placeholder-shown, :popover-open, :user-invalid, [aria-disabled="true"], [aria-expanded="true"], [aria-invalid="true"], [hidden], [multiple], [popover], [role="listbox"], [role="option"], [size].

    Variant attributes: data-status (error, warning); data-size (sm, lg); data-slean-active; data-slean-selected.

    Compatibility notes

    Popover is Baseline 2024; CSS anchor positioning (Chrome 125, Safari 26) places the popup under the field, and without it the popover popup is centered in the top layer. :has() and @media (scripting) are Baseline 2023. The owned field is drawn by the stylesheet, so it looks the same in Chromium, WebKit and Gecko; the customizable select only affects the Tier 0 select.

    Examples

    Groups

    Each <optgroup label> of the native select becomes a role="group" list with a label in the popup. A select without a placeholder option opens on its chosen option.

      groups
      <label id="office-label" for="office-native">Office</label>
      <div id="office" data-slean="select">
      	<select id="office-native" name="office" data-slean-part="native">
      		<optgroup label="Europe">
      			<option value="berlin">Berlin</option>
      			<option value="istanbul" selected>Istanbul</option>
      			<option value="lisbon">Lisbon</option>
      		</optgroup>
      		<optgroup label="Asia">
      			<option value="seoul">Seoul</option>
      			<option value="tokyo">Tokyo</option>
      		</optgroup>
      	</select>
      	<button type="button" role="combobox" aria-haspopup="listbox" aria-expanded="false"
      		aria-controls="office-list" aria-labelledby="office-label" data-slean-part="trigger">
      		<span data-slean-part="value">Istanbul</span>
      	</button>
      	<span data-slean-part="toggle" aria-hidden="true"></span>
      	<div popover="manual" data-slean-part="popup">
      		<!-- The behavior renders one role="group" with a group-label per <optgroup>. -->
      		<ul id="office-list" role="listbox" aria-labelledby="office-label"></ul>
      	</div>
      </div>

      Multiple

      A <select multiple> shows the chosen options as tags inside the field. The popup stays open while options are toggled; a tag’s remove button drops that option and Backspace drops the last one. The trigger contains buttons, so it is a focusable <div role="combobox">. Here the application renders the tags from its bound value (data-slean-render="app").

        The bound value: design, research

        teams.svelte
        <script lang="ts">
        	const TEAMS = [
        		{ value: 'design', label: 'Design' },
        		{ value: 'engineering', label: 'Engineering' },
        		{ value: 'research', label: 'Research' },
        		{ value: 'support', label: 'Support' },
        		{ value: 'sales', label: 'Sales' }
        	];
        	let teams = $state(['design', 'research']);
        	const label = (value: string) => TEAMS.find((t) => t.value === value)?.label ?? '';
        </script>
        
        <label id="teams-label" for="teams-native">Teams</label>
        <!-- data-slean-render="app": the application renders the tags; the behavior never writes them. -->
        <div id="teams" data-slean="select" data-slean-render="app">
        	<select id="teams-native" name="teams" multiple data-slean-part="native" bind:value={teams}>
        		{#each TEAMS as t (t.value)}
        			<option value={t.value}>{t.label}</option>
        		{/each}
        	</select>
        	<!-- The trigger holds buttons, so it is a focusable div, not a <button>. -->
        	<div role="combobox" tabindex="0" aria-haspopup="listbox" aria-expanded="false"
        		aria-controls="teams-list" aria-labelledby="teams-label" data-slean-part="trigger">
        		<span data-slean-part="value"
        			>{#each teams as t (t)}<span data-slean-part="tag" data-slean-value={t}
        					>{label(t)}<button type="button" tabindex="-1" aria-label="Remove {label(t)}"
        						data-slean-part="remove"></button></span
        				>{/each}</span
        		>
        		<span data-slean-part="placeholder">Select teams</span>
        	</div>
        	<button type="button" tabindex="-1" aria-label="Clear" data-slean-part="clear"></button>
        	<span data-slean-part="toggle" aria-hidden="true"></span>
        	<div popover="manual" data-slean-part="popup">
        		<ul id="teams-list" role="listbox" aria-labelledby="teams-label"></ul>
        	</div>
        </div>

        Sizes

        data-size="sm" and data-size="lg" on the root change the height, the padding and, for the large field, the text size; they are the heights of every field of control.css.

              sizes
              <div id="size-sm" data-slean="select" data-size="sm">…</div>
              <div id="size-md" data-slean="select">…</div>
              <div id="size-lg" data-slean="select" data-size="lg">…</div>

              Disabled and status

              disabled on the native select and on the trigger disables the whole field. An error is data-status="error" on the root or aria-invalid="true" on the trigger, with the message related through aria-describedby; data-status="warning" is the warning border.

                  Choose a region.

                  disabled and error
                  <!-- Disabled: disabled on the native select and on the trigger. -->
                  <div id="plan" data-slean="select">
                  	<select id="plan-native" name="plan" data-slean-part="native" disabled>…</select>
                  	<button type="button" role="combobox" … data-slean-part="trigger" disabled>…</button>
                  	…
                  </div>
                  
                  <!-- Error: data-status="error" on the root (or aria-invalid="true" on the trigger), with the
                       message related through aria-describedby. -->
                  <div id="region" data-slean="select" data-status="error">
                  	…
                  	<button type="button" role="combobox" … aria-invalid="true"
                  		aria-describedby="region-error" data-slean-part="trigger">…</button>
                  	…
                  </div>
                  <p id="region-error">Choose a region.</p>

                  Styled native select

                  A bare <select data-slean="select"> is the Tier 0 path: no behavior is registered for it and select.css styles the closed box, with the customizable select (appearance: base-select) drawing the picker where the browser has it. It is also how the native part of an owned select looks without JavaScript. Use it where the platform’s picker is wanted, for example in a dense toolbar.

                  Tier 0
                  <!-- The Tier 0 styled native select: no behavior, no JavaScript. -->
                  <label for="size">Font size</label>
                  <select id="size" name="size" data-slean="select" data-size="sm">
                  	<option>12</option>
                  	<option selected>14</option>
                  	<option>16</option>
                  </select>

                  Testing

                  Source