Styleframe Logo
Forms

Select

A multi-select form control composed of a trigger, a floating listbox panel, selectable options, dismissable value chips, a chevron indicator, group labels, and separators. Supports light, dark, and neutral colors, per-part visual styles, and three sizes through the recipe system.

Overview

The Select is a composite form control for choosing one or many values from a list. It is composed of seven recipe parts:

  • useSelectRecipe() — the trigger that owns the visual field (border, background, padding, :focus-within ring) and lays its contents out in a wrapping row so value chips and the chevron flow together.
  • useSelectPanelRecipe() — the floating listbox panel that holds the options, with a built-in scroll boundary for long lists.
  • useSelectOptionRecipe() — a selectable row with hover, focus, active, selected, and disabled states driven by ARIA attributes.
  • useSelectChipRecipe() — a dismissable tag rendered in the trigger for each selected value in multi-select mode, with a nested remove button.
  • useSelectArrowRecipe() — the chevron indicator that rotates when the panel is open.
  • useSelectLabelRecipe() — an uppercase heading for grouping options inside the panel.
  • useSelectSeparatorRecipe() — a thin rule for dividing option groups.

Each composable creates a fully configured recipe with color and size options — plus compound variants that handle every color-variant combination, including dark mode overrides, automatically.

The Select recipes integrate directly with the default design tokens preset and generate type-safe utility classes at build time with zero runtime CSS.

Why use the Select recipe?

The Select recipe helps you:

  • Ship faster with sensible defaults: Get a trigger, panel, options, chips, chevron, labels, and separators out of the box with a single set of composable calls.
  • Support multi-selection: Render each selected value as a dismissable chip directly in the trigger, modeled on the Badge recipe.
  • Maintain consistency: Compound variants ensure every color-variant combination follows the same design rules across all seven parts, including dark mode.
  • Customize without forking: Override base styles, default variants, or filter out options you don't need — all through the options API.
  • Stay type-safe: Full TypeScript support means your editor catches invalid color, variant, or size values at compile time.
  • Integrate with your tokens: Every value references the design tokens preset, so theme changes propagate automatically.

Usage

Register the recipes

Add the Select recipes to a local Styleframe instance. The global styleframe.config.ts provides design tokens and utilities, while the component-level file registers the recipes themselves:

src/components/select.styleframe.ts
import { styleframe } from 'virtual:styleframe';
import {
    useSelectRecipe,
    useSelectPanelRecipe,
    useSelectOptionRecipe,
    useSelectChipRecipe,
    useSelectArrowRecipe,
    useSelectLabelRecipe,
    useSelectSeparatorRecipe,
} from '@styleframe/theme';

const s = styleframe();

const select = useSelectRecipe(s);
const selectPanel = useSelectPanelRecipe(s);
const selectOption = useSelectOptionRecipe(s);
const selectChip = useSelectChipRecipe(s);
const selectArrow = useSelectArrowRecipe(s);
const selectLabel = useSelectLabelRecipe(s);
const selectSeparator = useSelectSeparatorRecipe(s);

export default s;

Build the component

Import the runtime functions from the virtual module. The trigger paints the field; the panel, options, chips, and chevron compose inside or below it. Selected and disabled options are driven by aria-selected and aria-disabled, and the chevron rotates when the trigger carries the -open class:

src/components/Select.tsx
import {
    select,
    selectPanel,
    selectOption,
    selectChip,
    selectArrow,
} from "virtual:styleframe";

interface SelectProps {
    color?: "light" | "dark" | "neutral";
    size?: "sm" | "md" | "lg";
    open?: boolean;
    values?: string[];
    options?: string[];
}

export function Select({
    color = "neutral",
    size = "md",
    open = false,
    values = [],
    options = [],
}: SelectProps) {
    const trigger = `${select({ color, variant: "solid", size })}${open ? " -open" : ""}`;

    return (
        <div>
            <div role="combobox" aria-haspopup="listbox" aria-expanded={open} className={trigger}>
                {values.map((value) => (
                    <span key={value} className={selectChip({ color, variant: "soft", size })}>
                        {value}
                        <button type="button" className="select-chip-remove" aria-label="Remove">
                            &times;
                        </button>
                    </span>
                ))}
                <span className={selectArrow({ size })} aria-hidden></span>
            </div>

            {open && (
                <ul role="listbox" className={selectPanel({ color, variant: "solid", size })}>
                    {options.map((option) => (
                        <li
                            key={option}
                            role="option"
                            aria-selected={values.includes(option)}
                            className={selectOption({ color, variant: "solid", size })}
                        >
                            <span className="select-option-check" aria-hidden>
                                &checkmark;
                            </span>
                            {option}
                        </li>
                    ))}
                </ul>
            )}
        </div>
    );
}

See it in action

Colors

The Select recipe includes 3 color variants: light, dark, and neutral. Like the Input and Card recipes, Select uses neutral-spectrum colors designed for content surfaces rather than status communication — the control's color reflects its surface, not a state. Every part shares the same color axis so the trigger, panel, options, and chips read as one control.

The neutral color adapts automatically: it uses a light appearance in light mode and a dark appearance in dark mode, making it the safest default for general-purpose forms.

Color Reference

ColorTokenUse Case
light@color.white / @color.gray-*Light surfaces, stays light in dark mode
dark@color.gray-900Dark surfaces, stays dark in light mode
neutralAdaptive (light ↔ dark)Default color, adapts to the current color scheme
Pro tip: Use neutral as your default select color. It adapts to the user's color scheme automatically, so you don't need to manage light and dark variants separately.

Variants

Select uses two visual-style vocabularies, each faithful to the recipe it is modeled on. Pass the right value to each part:

PartsVariantsModeled on
Trigger (useSelectRecipe)solid, soft, ghostInput
Panel + Option (useSelectPanelRecipe, useSelectOptionRecipe)solid, soft, subtleDropdown
Chip (useSelectChipRecipe)solid, outline, soft, subtleBadge

The stories below vary the trigger style. Each value is combined with the selected color through compound variants.

Solid

Opaque surface with a visible border — the standard bordered control. Reveals a primary-colored focus ring on :focus-within.

Soft

Tinted gray background with a matching border — a gentler, lower-contrast control that blends into dense forms.

Ghost

No background and no border until interaction — the control shows only the focus ring, ideal for inline and toolbar selects. On the panel and options, the matching value is subtle.

Sizes

Three size variants from sm to lg control font size, padding, and border radius. Pass the same size to every part — the trigger, panel, options, chips, and chevron all scale together.

Size Reference

SizeTrigger FontTrigger Padding (V / H)Border Radius
sm@font-size.xs@0.375 / @0.625@border-radius.sm
md@font-size.sm@0.5 / @0.75@border-radius.md
lg@font-size.md@0.625 / @0.875@border-radius.md

Single selection

For a single-value control, render the chosen value directly in the trigger instead of chips — optionally with a leading icon via the shared .select-icon slot (see Icons). The active option carries aria-selected="true", and the trigger reflects the current value. The same recipes power single- and multi-select; the trigger simply renders one value instead of a row of chips. A country selector is the canonical example:

<div class="select(...)" role="combobox" aria-expanded="true">
    <span class="select-icon">🇺🇸</span>
    <span class="select-value">United States</span>
    <span class="selectArrow(...)"></span>
</div>

Multi-selection

The headline feature: render each selected value as a dismissable chip inside the trigger. Chips are modeled on the Badge recipe but scoped to the container palette so they match the control surface. The trigger wraps its contents, so chips flow onto multiple rows as the selection grows, and the chevron stays pinned to the trailing edge.

Each chip registers a nested .select-chip-remove button that inherits the chip's text color and is sized in em so it scales with the chip. Supply the dismiss glyph (an × or an icon) and wire the click handler to remove the value:

<span class="select-chip ...">
    Engineering
    <button type="button" class="select-chip-remove" aria-label="Remove Engineering">&times;</button>
</span>
Pro tip: Give each remove button a descriptive aria-label that includes the value (for example, "Remove Engineering") so screen-reader users know exactly which selection they are dismissing.

Icons

A shared .select-icon slot adds a leading icon or media element — a country flag, an avatar, a status dot — to the trigger's selected value, to options, and to chips. It is a small flex box with flex-shrink: 0 so a long label never squashes it, sized in em so it scales with the surrounding font. Drop any <img>, <svg>, or emoji inside.

<!-- In an option: leading icon, trailing selected check -->
<li class="selectOption(...)" role="option" aria-selected="true">
    <span class="select-icon">🇺🇸</span>
    United States
    <span class="select-option-check"></span>
</li>

<!-- In the trigger's selected value -->
<div class="select(...)" role="combobox">
    <span class="select-icon">🇺🇸</span>
    <span class="select-value">United States</span>
    <span class="selectArrow(...)"></span>
</div>
Good to know:.select-icon is a styling slot, not a recipe — it has no color, variant, or size props. Apply the class directly and size the icon content with your own CSS if you need something other than the 1.25em default.

Options

Options live inside the panel as <li role="option"> rows. Their interactive states are driven by ARIA attributes rather than recipe axes, so a single source of truth controls both styling and semantics.

Selected

Set aria-selected="true" to apply a subtle tinted background, a medium font weight, and reveal the trailing .select-option-check indicator (pinned to the option's far edge via margin-left: auto). The check slot is always present in the markup but hidden until the option is selected, so rows stay aligned. The leading edge is free for an optional .select-icon.

Disabled

Set aria-disabled="true" (or the native disabled attribute on a button-based option) to dim the row, switch the cursor to not-allowed, and block pointer interaction.

Good to know: Because selected and disabled are attribute-driven, you set them once on the element and both the visuals and assistive-technology semantics stay in sync — no separate selected or disabled prop to keep aligned.

Anatomy

The Select is composed of seven independent recipes. The trigger holds the value chips and chevron; the panel holds the options, labels, and separators:

PartRecipeRole
TriggeruseSelectRecipe()The .select control — owns the visual field, wraps value chips, and exposes invalid / disabled / readonly states
PaneluseSelectPanelRecipe()The .select-panel floating listbox — scrollable, elevated surface for the options
OptionuseSelectOptionRecipe()A .select-option row — hover / focus / active plus aria-selected (trailing check) and aria-disabled states; an optional leading .select-icon
ChipuseSelectChipRecipe()A .select-chip dismissable tag for each selected value, with a nested .select-chip-remove button
ArrowuseSelectArrowRecipe()The .select-arrow chevron — inherits currentColor, rotates when the trigger is -open
LabeluseSelectLabelRecipe()A .select-label uppercase heading for grouping options
SeparatoruseSelectSeparatorRecipe()A .select-separator rule for dividing option groups
<!-- Trigger with value chips and chevron -->
<div class="select(...)" role="combobox" aria-expanded="true">
    <span class="selectChip(...)">Apple<button class="select-chip-remove">&times;</button></span>
    <span class="selectArrow(...)"></span>
</div>

<!-- Panel with grouped, selectable options -->
<ul class="selectPanel(...)" role="listbox">
    <li class="selectLabel(...)" role="presentation">Fruits</li>
    <li class="selectOption(...)" role="option" aria-selected="true">
        <span class="select-icon">🍎</span>Apple<span class="select-option-check"></span>
    </li>
    <li class="selectSeparator(...)" role="separator"></li>
    <li class="selectOption(...)" role="option" aria-disabled="true">Durian</li>
</ul>

Alongside the seven recipes, a few setup-registered helper slots carry no props of their own — apply the class directly: .select-value (trigger value text), .select-icon (shared leading media slot), .select-option-check (trailing selected indicator), and .select-chip-remove (chip dismiss button).

Pro tip: You don't need every part. A single-select with no grouping needs only the trigger, panel, and option recipes — reach for chips, labels, and separators when the control calls for them.

Accessibility

  • Wire the ARIA combobox pattern. The trigger should carry role="combobox", aria-haspopup="listbox", and aria-expanded; the panel role="listbox"; and each option role="option". The recipe styles the surfaces but does not manage the interaction — pair it with your framework's combobox logic or a headless library.
  • Drive selected and disabled with ARIA. Set aria-selected and aria-disabled on options so the visuals and the accessibility tree stay in sync. The selected tint and check indicator key off aria-selected="true".
  • Label each remove button. Give every .select-chip-remove an aria-label that names the value it dismisses, so the action is unambiguous to assistive technology.
  • Keep the chevron decorative. Mark the chevron aria-hidden="true" — it is a visual affordance, and the open state is already conveyed by aria-expanded.
  • Verify contrast ratios. The dark color places light text on a dark surface. Default tokens meet WCAG AA 4.5:1 contrast. If you override colors, verify with the WebAIM Contrast Checker.

Customization

Overriding Defaults

Each select composable accepts an optional second argument to override any part of the recipe configuration. Overrides are deep-merged with the defaults, so you only need to specify what you want to change:

src/components/select.styleframe.ts
import { styleframe } from 'virtual:styleframe';
import { useSelectRecipe } from '@styleframe/theme';

const s = styleframe();

const select = useSelectRecipe(s, {
    base: {
        borderRadius: '@border-radius.lg',
    },
    defaultVariants: {
        color: 'neutral',
        variant: 'soft',
        size: 'lg',
    },
});

export default s;

Filtering Variants

If you only need a subset of the available variants, use the filter option to limit which values are generated. This reduces the output CSS and keeps your component API focused:

src/components/select.styleframe.ts
import { styleframe } from 'virtual:styleframe';
import { useSelectRecipe } from '@styleframe/theme';

const s = styleframe();

// Only generate the neutral color with the solid and soft styles
const select = useSelectRecipe(s, {
    filter: {
        color: ['neutral'],
        variant: ['solid', 'soft'],
    },
});

export default s;
Good to know: Filtering also removes compound variants and adjusts default variants that reference filtered-out values, so your recipe stays consistent.

API Reference

useSelectRecipe(s, options?)

Creates the trigger recipe — the .select control that owns the visual field. Registers a nested .select-value selector (the placeholder/value slot) and a .select.-open selector (the open-state focus ring). Owns the full compound matrix: 9 color-variant combinations plus 3 state overrides.

Parameters:

ParameterTypeDescription
sStyleframeThe Styleframe instance
optionsDeepPartial<RecipeConfig>Optional overrides for the recipe configuration
options.baseVariantDeclarationsBlockCustom base styles for the trigger
options.variantsVariantsCustom variant definitions for the recipe
options.defaultVariantsRecord<keyof Variants, string>Default variant values for the recipe
options.compoundVariantsCompoundVariant[]Custom compound variant definitions for the recipe
options.filterRecord<string, string[]>Limit which variant values are generated

Variants:

VariantOptionsDefault
colorlight, dark, neutralneutral
variantsolid, soft, ghostsolid
sizesm, md, lgmd
invalidtrue, falsefalse
disabledtrue, falsefalse
readonlytrue, falsefalse

useSelectPanelRecipe(s, options?)

Creates the panel recipe — the .select-panel floating listbox. Adds a maxHeight and overflowY: auto so long option lists scroll. Accepts the same parameters as useSelectRecipe.

Variants:

VariantOptionsDefault
colorlight, dark, neutralneutral
variantsolid, soft, subtlesolid
sizesm, md, lgmd

useSelectOptionRecipe(s, options?)

Creates the option recipe — a .select-option row with hover, focus, and active states per color-variant, plus aria-selected and aria-disabled handling. Registers a nested .select-option-check slot revealed when the option is selected. Accepts the same parameters as useSelectRecipe.

Variants:

VariantOptionsDefault
colorlight, dark, neutralneutral
variantsolid, soft, subtlesolid
sizesm, md, lgmd

useSelectChipRecipe(s, options?)

Creates the chip recipe — a .select-chip dismissable tag scoped to the container palette. Registers a nested .select-chip-remove button. Accepts the same parameters as useSelectRecipe.

Variants:

VariantOptionsDefault
colorlight, dark, neutralneutral
variantsolid, outline, soft, subtlesoft
sizesm, md, lgsm

useSelectArrowRecipe(s, options?)

Creates the chevron recipe — a .select-arrow indicator that inherits currentColor and rotates 180° when its data-open="true" attribute is set or its parent trigger carries -open. Has only a size axis. Accepts the same parameters as useSelectRecipe.

Variants:

VariantOptionsDefault
sizesm, md, lgmd

useSelectLabelRecipe(s, options?)

Creates the label recipe — a .select-label uppercase heading for grouping options. Has color and size axes and no variant axis. Accepts the same parameters as useSelectRecipe.

Variants:

VariantOptionsDefault
colorlight, dark, neutralneutral
sizesm, md, lgmd

useSelectSeparatorRecipe(s, options?)

Creates the separator recipe — a .select-separator rule for dividing option groups. Has a color axis only. Accepts the same parameters as useSelectRecipe.

Variants:

VariantOptionsDefault
colorlight, dark, neutralneutral

Learn more about recipes →

Best Practices

  • Pass color and size consistently: Spread the same color and size to every part so the whole control scales and themes together.
  • Use the right variant per part: solid/soft/ghost for the trigger, solid/soft/subtle for the panel and options, and the badge vocabulary for chips.
  • Drive option state with ARIA: Set aria-selected and aria-disabled on options instead of toggling classes by hand.
  • Label remove buttons: Give each .select-chip-remove an aria-label that names the value it dismisses.
  • Use neutral for general forms: The neutral color adapts to light and dark mode automatically, making it the safest default.
  • Filter what you don't need: If your control uses only one color or two variants, pass a filter option to reduce generated CSS.

FAQ