FUNSTACK UI

HomeGetting startedConceptsComponentsRoadmap
GitHub

FUNSTACK UI is experimental. Part of the FUNSTACK family with FUNSTACK Router and FUNSTACK Static, which build this site.

Introduction

  • Overview
  • Getting started

Concepts

  • Action props
  • Pending & optimistic state
  • Transitions & ViewTransition
  • Suspense
  • Server Components
  • Browser-owned state
  • Experimental features

Components

  • Toggle
  • Tabs
  • Accordion
  • Tooltip
  • Dialog
  • Popover
  • Menu
  • Switch
  • Checkbox
  • RadioGroup
  • CheckboxGroup
  • Select
  • Combobox
  • Toast
  • Slider
  • Field & Form

Resources

  • Roadmap & changelog
  • GitHub ↗

Select

A real <select>. The platform gives it form participation, required, autofill, typeahead, option groups, the native combobox semantics, mobile pickers, and interaction before hydration. Opt into a styled picker with one CSS declaration, appearance: base-select; the library ships the markup for it and no JavaScript for the picker. What the library adds is the same as for RadioGroup: the selection changes through valueChangeAction in a transition, with optimistic feedback and data-pending, plus fixes for three things React gets wrong with a <select>.

saved: ""
Show code
SelectFruit.tsx
"use client";
import { startTransition, useState } from "react";
import { Select } from "@funstack/ui/select";
import { fakeSave } from "./fake-api.js";

/** A decorative swatch: an SVG with no text, so the option still reads as plain text. */
function Swatch({ color }: { color: string }) {
  return (
    <svg className="demo-select-swatch" viewBox="0 0 10 10" aria-hidden="true">
      <circle cx="5" cy="5" r="5" fill={color} />
    </svg>
  );
}

// Uncontrolled with an async save. The pick shows at once (the <select> renders the optimistic
// value); the select, its button and the picked option carry data-pending for the 800 ms the save
// takes, and the Trigger's function children add a spinner. With `appearance: base-select`
// (Chromium, Safari 27) the button and the picker are styled; Firefox renders the native select
// from the same markup.
export function SelectFruit() {
  const [saved, setSaved] = useState("");
  return (
    <div className="demo-stack">
      <label className="demo-field" htmlFor="fruit">
        Favourite fruit
      </label>
      <Select.Root
        id="fruit"
        className="demo-select"
        defaultValue=""
        valueChangeAction={async (next) => {
          await fakeSave(`fruit=${next}`);
          startTransition(() => setSaved(next));
        }}
      >
        <Select.Trigger className="demo-select-button">
          {({ pending }) => (
            <>
              <Select.Value />
              {pending && <span className="demo-spinner" aria-hidden />}
            </>
          )}
        </Select.Trigger>
        <Select.Option value="" disabled>
          Choose a fruit
        </Select.Option>
        <Select.Group label="Citrus">
          <Select.GroupLabel>Citrus</Select.GroupLabel>
          <Select.Option value="orange">
            <Swatch color="orange" /> Orange
          </Select.Option>
          <Select.Option value="lemon">
            <Swatch color="gold" /> Lemon
          </Select.Option>
        </Select.Group>
        <Select.Separator />
        <Select.Option value="apple" disabled>
          <Swatch color="crimson" /> Apple (out of season)
        </Select.Option>
        <Select.Option value="banana">
          <Swatch color="khaki" /> Banana
        </Select.Option>
      </Select.Root>
      <output className="demo-readout">
        saved: <code>{JSON.stringify(saved)}</code>
      </output>
    </div>
  );
}
select.css
/* Select: the Root is a real <select>. The opt-in to the styled picker is `appearance:
   base-select` on the select and on its ::picker(select); engines without customizable select
   (Firefox today) keep the native select and ignore the rest. Two rules, because an engine that
   does not know `::picker(select)` drops a whole rule that names it. */
.demo-select {
  appearance: base-select;
  min-inline-size: 14rem;
  padding: 0.4em 0.6em;
  border: 1px solid var(--color-border);
  border-radius: 6px;
  background: var(--color-bg);
  color: var(--color-text);
  font: inherit;
}
.demo-select::picker(select) {
  appearance: base-select;
}

.demo-select[data-pending] {
  cursor: progress;
}
.demo-select[data-pending] selectedcontent {
  opacity: 0.6;
}
.demo-select:user-invalid {
  border-color: #dc2626;
}
.demo-select::picker-icon {
  content: "▾";
  transition: rotate 120ms;
}
.demo-select:open::picker-icon {
  rotate: 180deg;
}

/* The select's own <button> (Select.Trigger): the UA makes it `display: contents`; lay out what
   is inside it instead. */
.demo-select-button selectedcontent,
.demo-select selectedcontent {
  display: inline-flex;
  align-items: center;
  gap: 0.5em;
}
.demo-select-button .demo-spinner {
  margin-inline-start: 0.5em;
}

/* The picker and its options. */
.demo-select::picker(select) {
  margin-block: 4px;
  padding: 4px;
  border: 1px solid var(--color-border);
  border-radius: 8px;
  background: var(--color-bg);
  color: var(--color-text);
  opacity: 0;
  transition:
    opacity 120ms,
    overlay 120ms allow-discrete,
    display 120ms allow-discrete;
}
.demo-select:open::picker(select) {
  opacity: 1;
  @starting-style {
    opacity: 0;
  }
}
.demo-select option {
  display: flex;
  align-items: center;
  gap: 0.5em;
  padding: 0.4em 0.6em;
  border-radius: 4px;
}
.demo-select option:hover,
.demo-select option:focus-visible {
  background: var(--color-primary);
  color: var(--color-on-primary);
}
.demo-select option:checked {
  font-weight: 600;
}
.demo-select option::checkmark {
  content: "✓";
  order: 1;
  margin-inline-start: auto;
}
.demo-select option[data-pending]::after {
  content: "…";
}
.demo-select option:disabled {
  opacity: 0.45;
}
.demo-select optgroup legend {
  padding: 0.3em 0.6em 0.1em;
  font-size: 0.85em;
  color: var(--color-text-secondary);
}
.demo-select hr {
  margin: 4px 0;
  border: 0;
  border-block-start: 1px solid var(--color-border);
}

/* Icons: the clone in <selectedcontent> is a copy, so style it there too. */
.demo-select-swatch {
  flex: none;
  inline-size: 0.8em;
  block-size: 0.8em;
}

@media (prefers-reduced-motion: reduce) {
  .demo-select::picker(select),
  .demo-select::picker-icon {
    transition: none;
  }
}

Anatomy

import { Select } from "@funstack/ui/select";

<Select.Root name="fruit" defaultValue="" required valueChangeAction={saveFruit}> {/* select */}
  <Select.Trigger>                                  {/* button: optional, first child */}
    <Select.Value />                                {/* selectedcontent: the browser fills it */}
  </Select.Trigger>
  <Select.Option value="">Choose a fruit</Select.Option>     {/* option: the placeholder */}
  <Select.Group label="Citrus">                     {/* optgroup */}
    <Select.GroupLabel>Citrus <em>fruits</em></Select.GroupLabel>  {/* legend: optional */}
    <Select.Option value="orange"><Icon aria-hidden /> Orange</Select.Option>
  </Select.Group>
  <Select.Separator />                              {/* hr */}
  <Select.Option value="banana">Banana</Select.Option>
</Select.Root>
  • Root is the <select> itself: value, form, validation, picker, keyboard and accessibility mapping all live on that element. A render element must stay a <select>. There is no open state: the picker is the browser's ::picker(select), opened and closed with no events. Style it with :open.
  • Trigger and Value are optional. Without them, browsers with customizable select draw their own button with the selected option's text. Add them for rich button content: Select.Value is a <selectedcontent> into which the browser copies the selected option's content. It has no children. The Trigger must be the select's first child.
  • Option content must read as plain text. Firefox (default settings) shows the native select, which displays only the text. Give icons aria-hidden and no text (an SVG, or alt=""), and avoid the label attribute: Chromium names the option from it but shows the content.
  • Group needs label: native pickers and assistive technology read the attribute. GroupLabel (a <legend>, first child of its Group) is the rich label drawn by customizable select; keep its text equal to label.
  • No Popup, Positioner, Icon or Indicator parts: the picker, the arrow (::picker-icon) and the check mark (::checkmark) are pseudo-elements.

Styling the picker

The library ships no CSS. These recipes cover the opt-in, the button, the picker, its options and an open/close animation. Engines without customizable select ignore all of it except the plain properties on the select.

/* 1. Opt in to the customizable picker. Two rules: an engine that does not know
      ::picker(select) drops any rule naming it. */
.select { appearance: base-select; }
.select::picker(select) { appearance: base-select; }

/* 2. The button, its content, the arrow. */
.select { border: 1px solid GrayText; border-radius: 6px; padding: 0.4em 0.6em; }
.select::picker-icon { content: "▾"; transition: rotate 120ms; }
.select:open::picker-icon { rotate: 180deg; }
.select[data-pending] { cursor: progress; }
.select[data-pending] selectedcontent { opacity: 0.6; }
.select:user-invalid { border-color: var(--danger); }
selectedcontent .icon { inline-size: 1em; }          /* the copy, not the option */

/* 3. The picker and its options. */
.select::picker(select) { border: 1px solid GrayText; border-radius: 8px; padding: 4px; }
.select option { padding: 0.4em 0.6em; border-radius: 4px; }
.select option:hover, .select option:focus-visible { background: Highlight; color: HighlightText; }
.select option:checked { font-weight: 600; }          /* optimistic: flips on pick, reverts on rejection */
.select option::checkmark { content: "✓"; order: 1; margin-inline-start: auto; }
.select option[data-pending]::after { content: "…"; }
.select option:disabled { opacity: 0.4; }
.select optgroup legend { font-size: 0.85em; color: GrayText; }

/* 4. Open/close animation (Chromium; Safari 27.0 does not animate the picker yet). */
.select::picker(select) {
  opacity: 0;
  transition: opacity 120ms, overlay 120ms allow-discrete, display 120ms allow-discrete;
}
.select:open::picker(select) { opacity: 1; @starting-style { opacity: 0; } }

/* 5. Placement: the browser anchors the picker to the select; override its position. */
.select::picker(select) { position-area: block-end span-inline-end; position-try-fallbacks: flip-block; }

@media (prefers-reduced-motion: reduce) {
  .select::picker(select), .select::picker-icon { transition: none; }
}

Tailwind: data-pending:cursor-progress open:ring-2 on the Root, checked:font-semibold data-pending:animate-pulse on an Option.

Safari: a Select inside a Popover or Menu

The browser positions the picker with anchor positioning and fallbacks. A WebKit bug (302008) can hang a page that opens a popover with position fallbacks inside another popover. If a Select inside a Popover or Menu misbehaves in Safari, turn the fallbacks off: .select::picker(select) { position-try-fallbacks: none; }.

Props

Select.Root

PropTypeDefaultDescription
valuestring–Controlled value. undefined means uncontrolled. "" selects the placeholder option.
defaultValuestringthe browser's choiceInitial value in uncontrolled mode, and what a form reset returns to (the latest value is mirrored into the options' defaultSelected). Without it (and without value) the Select follows the browser: an option with value="", else the first enabled option, is adopted at mount without calling the action, and a reset returns to that adopted option (a disabled "" placeholder included). Until a pick sets the value, options that load later behind a fallback are adopted again; after one, the pick is kept.
valueChangeActionChangeAction<string, SelectValueChangeDetails>–Runs in the Root's transition whenever the selected option changes; sync, async or a Server Function. Rejections reach the nearest Error Boundary and roll the selection back. In engines whose closed select changes the value on every arrow press (Firefox, Safari's native select) every press dispatches; the latest wins.
disabledbooleanfalseNative disabled. Never set by the library while pending (a disabled select would also drop out of the form data).
name, required, form, autoCompletenative–Plain <select> props. With required, the placeholder (value="") fails validation.
classNamestring | ((state: SelectRootState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: SelectRootState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: SelectRootState) => ReactNode)–The options and the Trigger. The function form receives the optimistic value; must not suspend.
renderReactElement | ((props, state: SelectRootState) => ReactNode)–Replace the default <select> (element form) or take over rendering (function form).

SelectRootState: { value, pending, disabled }, with value optimistic. The Root owns value, disabled and onChange. multiple and size are not accepted: multi-select is not part of this version. id, aria-*, onFocus and other select props pass through.

SelectValueChangeDetails: { reason, event }. reason is "activate" for a pick (pointer, keyboard, typeahead or autofill; event is the native change) and "platform" for a form reset (event is the reset event) or a pick made before hydration (no event).

Select.Trigger

PropTypeDefaultDescription
classNamestring | ((state: SelectRootState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: SelectRootState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: SelectRootState) => ReactNode)–Non-interactive content, usually <Select.Value />; a function of the Root's state for a pending indicator.
renderReactElement | ((props, state: SelectRootState) => ReactNode)–Replace the default <button> (element form) or take over rendering (function form).

Renders <button type="button"> with data-pending and data-disabled; owns type. Must be the select's first child. Engines without customizable select do not render it, so a spinner inside it also needs a select[data-pending] style.

Select.Value

PropTypeDefaultDescription
classNamestring | ((state: SelectRootState) => string | undefined)–A class name, or a function of the Root's state.
styleCSSProperties | ((state: SelectRootState) => CSSProperties | undefined)–Inline style, or a function of the Root's state.
renderReactElement | ((props, state: SelectRootState) => ReactNode)–Replace the default <selectedcontent> (element form) or take over rendering (function form).

No children: the browser copies the selected option's content in on every selection change, and the parser does so before hydration (the element is rendered so that hydration accepts that content). The library keeps the copy fresh: when the selected option's content changes through React (a translated label, a count, the option's own function children, a Suspense boundary resolving inside it) or a form reset changes the selection, it asks the browser to copy again. The copy is a snapshot: style it as selectedcontent .icon, not option:checked .icon.

Select.Option

PropTypeDefaultDescription
valuestring–Required (React cannot infer a value from rich content). Submitted as the form value.
disabledbooleanfalseNative disabled: not pickable, skipped by keyboard navigation.
classNamestring | ((state: SelectOptionState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: SelectOptionState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: SelectOptionState) => ReactNode)–Content, or a function of the option's state.
renderReactElement | ((props, state: SelectOptionState) => ReactNode)–Replace the default <option> (element form) or take over rendering (function form).

SelectOptionState: { value, selected, pending, disabled }. selected is optimistic, pending is true on the option the user is waiting on, disabled includes a disabled Select.Group and Root disabled. The Option owns value, selected (never rendered) and disabled.

Select.Group, Select.GroupLabel, Select.Separator

PropTypeDefaultDescription
label (Group)string–Required. The <optgroup label>, shown by native pickers and read by assistive technology.
disabled (Group)booleanfalseNative: disables every option in the group.
classNamestring | ((state: SelectRootState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: SelectRootState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: SelectRootState) => ReactNode)–Group and GroupLabel: content.
renderReactElement | ((props, state: SelectRootState) => ReactNode)–Replace the default <optgroup> (must stay one), <legend> or <hr>. Separator takes no children.

Data attributes and pseudo-classes

AttributeOnPseudo-classMeaning
data-pendingRoot, Trigger–valueChangeAction is in flight.
data-pendingOption–The option the in-flight dispatch is selecting.
data-selectedOption:checkedOptimistic selection. Identical, because the select renders the optimistic value.
data-disabledRoot, Trigger, Option:disabledDisabled by prop; on an Option, by its own prop, a disabled Group or Root disabled.
–Root:open, :focus-withinThe picker is open. Browser-owned, no attribute. In Chromium focus moves to the option while open, so select:focus-within is the robust focus selector.
–Root:required, :user-invalid, :validPlatform only.

Keyboard and ARIA

Everything is the browser's; the library installs no key handler. The engines differ, and the Select's contract does not depend on them: one valueChangeAction per committed change, with the optimistic value in the DOM.

KeyChromium (customizable select)Firefox, Safari (native)Dispatches
Tab / Shift+TabFocuses the select; while open, closes without committing and focus returns to the select.Focuses the select.no
Enter, Space, ArrowDown (closed)Opens the picker; focus moves to the selected option.Space / Alt+Down open the native popup; arrows change the value at once.Chromium: no. Native: once per arrow press
Arrows, Home, End (open)Move focus between enabled options.Inside the native popup.no
Enter, Space, click (open)Commits the focused option and closes.Pick.once
Escape (open)Closes without committing.Closes the native popup.no
TypeaheadClosed: selects the match at once. Open: moves focus to it.Selects the match.once per match while closed
  • The select is exposed as a combobox with a popup; options are named from their content (aria-hidden icons excluded), groups are group and, in Chromium's customizable select, the separator is a separator. Name the select with a <label for> or aria-label.
  • The library never moves focus, not even on rollback. A rollback is silent; add an aria-live region if a slow action may fail.
  • In Firefox and Safari's native select every arrow press is a committed change. For a preference that needs no wait, do not return the promise ((next) => { void save(next); }), or leave the value to the form submit.

Async walkthrough

  1. Pick Banana: the browser selects it, copies it into the button and fires change. The Root dispatches valueChangeAction("banana", { reason: "activate" }) in its transition. The Root, the Trigger and the Banana option get data-pending.
  2. Resolve: the value is committed (uncontrolled) and the attributes go.
  3. Reject: the optimistic value reverts, React selects the old option again (the button follows), and the error reaches the nearest Error Boundary.
  4. A controlled parent vetoes by not adopting the value: the button shows Banana for the action's duration, then the old option again.

Why the select shows the optimistic value

After every event react-dom restores a controlled select's options to its value prop. Only the synchronous optimistic state has rendered by then, so a select driven by committed state would snap back (button included) for the whole pending time. Rendering the optimistic value is a correctness requirement.

Options that load later

value: "br"
Show code
SelectCountry.tsx
"use client";
import { Suspense, use, useState } from "react";
import { Select } from "@funstack/ui/select";
import { sleep } from "./fake-api.js";

interface Country {
  readonly code: string;
  readonly name: string;
}

const initialCountries: readonly Country[] = [
  { code: "jp", name: "Japan" },
  { code: "fr", name: "France" },
  { code: "br", name: "Brazil" },
];

async function fetchCountries(): Promise<readonly Country[]> {
  await sleep(1500);
  return [...initialCountries, { code: "ke", name: "Kenya" }];
}

function CountryOptions({ countries }: { countries: Promise<readonly Country[]> | null }) {
  const list = countries === null ? initialCountries : use(countries);
  return list.map((c) => (
    <Select.Option key={c.code} value={c.code}>
      {c.name}
    </Select.Option>
  ));
}

// The options sit behind a Suspense boundary inside the select. "Reload" remounts the boundary
// (a new `key`), so the fallback option replaces the list for 1.5 s and the real options mount
// later. React selects options only in the select's own commit, so on its own it would leave the
// browser's first option selected; the library re-applies the value (`br`) when they arrive.
export function SelectCountry() {
  const [country, setCountry] = useState("br");
  const [countries, setCountries] = useState<Promise<readonly Country[]> | null>(null);
  const [generation, setGeneration] = useState(0);
  return (
    <div className="demo-stack">
      <div className="demo-row">
        <Select.Root
          aria-label="Country"
          className="demo-select"
          value={country}
          valueChangeAction={(next) => {
            setCountry(next);
          }}
        >
          <Select.Trigger className="demo-select-button">
            <Select.Value />
          </Select.Trigger>
          <Suspense
            key={generation}
            fallback={
              <Select.Option value="" disabled>
                Loading countries…
              </Select.Option>
            }
          >
            <CountryOptions countries={countries} />
          </Suspense>
        </Select.Root>
        <button
          type="button"
          className="demo-button"
          onClick={() => {
            setCountries(fetchCountries());
            setGeneration((g) => g + 1);
          }}
        >
          Reload
        </button>
      </div>
      <output className="demo-readout">
        value: <code>{JSON.stringify(country)}</code>
      </output>
    </div>
  );
}
select.css
/* Select: the Root is a real <select>. The opt-in to the styled picker is `appearance:
   base-select` on the select and on its ::picker(select); engines without customizable select
   (Firefox today) keep the native select and ignore the rest. Two rules, because an engine that
   does not know `::picker(select)` drops a whole rule that names it. */
.demo-select {
  appearance: base-select;
  min-inline-size: 14rem;
  padding: 0.4em 0.6em;
  border: 1px solid var(--color-border);
  border-radius: 6px;
  background: var(--color-bg);
  color: var(--color-text);
  font: inherit;
}
.demo-select::picker(select) {
  appearance: base-select;
}

.demo-select[data-pending] {
  cursor: progress;
}
.demo-select[data-pending] selectedcontent {
  opacity: 0.6;
}
.demo-select:user-invalid {
  border-color: #dc2626;
}
.demo-select::picker-icon {
  content: "▾";
  transition: rotate 120ms;
}
.demo-select:open::picker-icon {
  rotate: 180deg;
}

/* The select's own <button> (Select.Trigger): the UA makes it `display: contents`; lay out what
   is inside it instead. */
.demo-select-button selectedcontent,
.demo-select selectedcontent {
  display: inline-flex;
  align-items: center;
  gap: 0.5em;
}
.demo-select-button .demo-spinner {
  margin-inline-start: 0.5em;
}

/* The picker and its options. */
.demo-select::picker(select) {
  margin-block: 4px;
  padding: 4px;
  border: 1px solid var(--color-border);
  border-radius: 8px;
  background: var(--color-bg);
  color: var(--color-text);
  opacity: 0;
  transition:
    opacity 120ms,
    overlay 120ms allow-discrete,
    display 120ms allow-discrete;
}
.demo-select:open::picker(select) {
  opacity: 1;
  @starting-style {
    opacity: 0;
  }
}
.demo-select option {
  display: flex;
  align-items: center;
  gap: 0.5em;
  padding: 0.4em 0.6em;
  border-radius: 4px;
}
.demo-select option:hover,
.demo-select option:focus-visible {
  background: var(--color-primary);
  color: var(--color-on-primary);
}
.demo-select option:checked {
  font-weight: 600;
}
.demo-select option::checkmark {
  content: "✓";
  order: 1;
  margin-inline-start: auto;
}
.demo-select option[data-pending]::after {
  content: "…";
}
.demo-select option:disabled {
  opacity: 0.45;
}
.demo-select optgroup legend {
  padding: 0.3em 0.6em 0.1em;
  font-size: 0.85em;
  color: var(--color-text-secondary);
}
.demo-select hr {
  margin: 4px 0;
  border: 0;
  border-block-start: 1px solid var(--color-border);
}

/* Icons: the clone in <selectedcontent> is a copy, so style it there too. */
.demo-select-swatch {
  flex: none;
  inline-size: 0.8em;
  block-size: 0.8em;
}

@media (prefers-reduced-motion: reduce) {
  .demo-select::picker(select),
  .demo-select::picker-icon {
    transition: none;
  }
}

React selects options only in the select's own commit, so options revealed later by a Suspense boundary or use() inside the select would leave the browser's first option selected. The library re-applies the value when they arrive, without a dispatch. Until then nothing (or the fallback) is selected and the button is empty. Use a disabled fallback option, or put the whole Select inside the boundary for a skeleton. There is no "load on open": a native select has no open event (use a Combobox for that).

Forms

ordered: ""
valueChangeAction calls, latest first: (none yet)
Show code
SelectForm.tsx
"use client";
import { startTransition, useState } from "react";
import { useFormStatus } from "react-dom";
import { Select } from "@funstack/ui/select";
import type { SelectValueChangeDetails } from "@funstack/ui/select";
import { fakeSave } from "./fake-api.js";

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" className="demo-button demo-primary">
      {pending && <span className="demo-spinner" aria-hidden />}
      Order
    </button>
  );
}

// The select posts `size=…` with the form (a real `name`). The first option is the placeholder:
// `value=""`, so `required` blocks the submit until a real size is picked, and the select matches
// `:user-invalid` after the attempt. After the form action succeeds React resets the form; the
// ordered size is the new `defaultValue`, so the reset returns to it. `valueChangeAction` only
// records what the Select was told: picks arrive with reason "activate", resets with "platform".
export function SelectForm() {
  const [ordered, setOrdered] = useState("");
  const [log, setLog] = useState<string[]>([]);
  const record = (next: string, { reason, event }: SelectValueChangeDetails) => {
    const entry = `${JSON.stringify(next)} (${reason}, ${event?.type ?? "no event"})`;
    setLog((prev) => [entry, ...prev].slice(0, 4));
  };
  return (
    <form
      className="demo-stack"
      action={async (formData) => {
        await fakeSave("the order");
        const size = formData.get("size");
        startTransition(() => setOrdered(typeof size === "string" ? size : ""));
      }}
    >
      <label className="demo-field" htmlFor="size">
        T-shirt size
      </label>
      <Select.Root
        id="size"
        className="demo-select"
        name="size"
        required
        defaultValue={ordered}
        valueChangeAction={record}
      >
        <Select.Option value="" disabled>
          Choose a size
        </Select.Option>
        <Select.Option value="s">Small</Select.Option>
        <Select.Option value="m">Medium</Select.Option>
        <Select.Option value="l">Large</Select.Option>
        <Select.Option value="xl" disabled>
          Extra large (sold out)
        </Select.Option>
      </Select.Root>
      <div className="demo-row">
        <SubmitButton />
        <button type="reset" className="demo-button">
          Reset
        </button>
      </div>
      <output className="demo-readout">
        ordered: <code>{JSON.stringify(ordered)}</code>
        <br />
        valueChangeAction calls, latest first: <code>{log.join(" · ") || "(none yet)"}</code>
      </output>
    </form>
  );
}
select.css
/* Select: the Root is a real <select>. The opt-in to the styled picker is `appearance:
   base-select` on the select and on its ::picker(select); engines without customizable select
   (Firefox today) keep the native select and ignore the rest. Two rules, because an engine that
   does not know `::picker(select)` drops a whole rule that names it. */
.demo-select {
  appearance: base-select;
  min-inline-size: 14rem;
  padding: 0.4em 0.6em;
  border: 1px solid var(--color-border);
  border-radius: 6px;
  background: var(--color-bg);
  color: var(--color-text);
  font: inherit;
}
.demo-select::picker(select) {
  appearance: base-select;
}

.demo-select[data-pending] {
  cursor: progress;
}
.demo-select[data-pending] selectedcontent {
  opacity: 0.6;
}
.demo-select:user-invalid {
  border-color: #dc2626;
}
.demo-select::picker-icon {
  content: "▾";
  transition: rotate 120ms;
}
.demo-select:open::picker-icon {
  rotate: 180deg;
}

/* The select's own <button> (Select.Trigger): the UA makes it `display: contents`; lay out what
   is inside it instead. */
.demo-select-button selectedcontent,
.demo-select selectedcontent {
  display: inline-flex;
  align-items: center;
  gap: 0.5em;
}
.demo-select-button .demo-spinner {
  margin-inline-start: 0.5em;
}

/* The picker and its options. */
.demo-select::picker(select) {
  margin-block: 4px;
  padding: 4px;
  border: 1px solid var(--color-border);
  border-radius: 8px;
  background: var(--color-bg);
  color: var(--color-text);
  opacity: 0;
  transition:
    opacity 120ms,
    overlay 120ms allow-discrete,
    display 120ms allow-discrete;
}
.demo-select:open::picker(select) {
  opacity: 1;
  @starting-style {
    opacity: 0;
  }
}
.demo-select option {
  display: flex;
  align-items: center;
  gap: 0.5em;
  padding: 0.4em 0.6em;
  border-radius: 4px;
}
.demo-select option:hover,
.demo-select option:focus-visible {
  background: var(--color-primary);
  color: var(--color-on-primary);
}
.demo-select option:checked {
  font-weight: 600;
}
.demo-select option::checkmark {
  content: "✓";
  order: 1;
  margin-inline-start: auto;
}
.demo-select option[data-pending]::after {
  content: "…";
}
.demo-select option:disabled {
  opacity: 0.45;
}
.demo-select optgroup legend {
  padding: 0.3em 0.6em 0.1em;
  font-size: 0.85em;
  color: var(--color-text-secondary);
}
.demo-select hr {
  margin: 4px 0;
  border: 0;
  border-block-start: 1px solid var(--color-border);
}

/* Icons: the clone in <selectedcontent> is a copy, so style it there too. */
.demo-select-swatch {
  flex: none;
  inline-size: 0.8em;
  block-size: 0.8em;
}

@media (prefers-reduced-motion: reduce) {
  .demo-select::picker(select),
  .demo-select::picker-icon {
    transition: none;
  }
}
  • The value is always a string. "Nothing chosen" is the HTML placeholder: a first <Select.Option value="">, which required rejects with the browser's own message and :user-invalid. It may be disabled so that it cannot be picked again.
  • Form reset (the Reset button, form.reset(), or React's automatic reset after the action succeeds) is one dispatch with reason: "platform" and the reset event. Uncontrolled, the target is the latest defaultValue (without one, the option adopted at mount); controlled, the Root's mount-time value, offered to the parent. A disabled Select is reset too.
  • Picks before hydration (and Firefox restoring form state on reload) work natively; on hydration the Root finds the DOM differs from its state and dispatches once, with no event. That includes options that hydrate later inside a Suspense boundary.

Browser support

Chromium 135+Safari 27Firefox (default settings)
appearance: base-selectstyled pickerstyled pickerignored: native select
Trigger (<button>)replaces the UA buttonsameparsed, not rendered
Value (<selectedcontent>)filled by the browsersamestays empty
Rich option content, GroupLabelrenderedrenderedthe option text; the group's label
Value, forms, required, reset, actions, pendingidenticalidenticalidentical

No experimental flag: the markup works everywhere and the opt-in is your CSS. Whether Safari on iOS draws the styled picker in the page or the system wheel is not verified yet; the value contract is the same either way (wrap the opt-in in @media (pointer: fine) if you need the wheel). Search, free text and async loading on open are Combobox's job.

Server Functions and forms

// Autosave: an action with data-pending, passed directly from a Server Component.
<Select.Root defaultValue={user.country} valueChangeAction={setCountry}>...</Select.Root>

// Native submission: no action on the Select; pending is the form's.
<form action={placeOrder}>
  <Select.Root name="size" defaultValue="" required>...</Select.Root>
  <SubmitButton />
</form>

The two channels are independent and may be combined. Every dispatch adds the view transition type fsui-select; no part takes a transition prop, since the picker and the button's copy are drawn by the browser. An action-less Select is a working native select before hydration, including the copy into the button.