Select
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-withinring) and lays its contents out in a wrapping row so value chips and the chevron flow together.useSelectPanelRecipe()— the floatinglistboxpanel 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:
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:
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">
×
</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>
✓
</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
| Color | Token | Use Case |
|---|---|---|
light | @color.white / @color.gray-* | Light surfaces, stays light in dark mode |
dark | @color.gray-900 | Dark surfaces, stays dark in light mode |
neutral | Adaptive (light ↔ dark) | Default color, adapts to the current color scheme |
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:
| Parts | Variants | Modeled on |
|---|---|---|
Trigger (useSelectRecipe) | solid, soft, ghost | Input |
Panel + Option (useSelectPanelRecipe, useSelectOptionRecipe) | solid, soft, subtle | Dropdown |
Chip (useSelectChipRecipe) | solid, outline, soft, subtle | Badge |
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
| Size | Trigger Font | Trigger 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">×</button>
</span>
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>
.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.
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:
| Part | Recipe | Role |
|---|---|---|
| Trigger | useSelectRecipe() | The .select control — owns the visual field, wraps value chips, and exposes invalid / disabled / readonly states |
| Panel | useSelectPanelRecipe() | The .select-panel floating listbox — scrollable, elevated surface for the options |
| Option | useSelectOptionRecipe() | A .select-option row — hover / focus / active plus aria-selected (trailing check) and aria-disabled states; an optional leading .select-icon |
| Chip | useSelectChipRecipe() | A .select-chip dismissable tag for each selected value, with a nested .select-chip-remove button |
| Arrow | useSelectArrowRecipe() | The .select-arrow chevron — inherits currentColor, rotates when the trigger is -open |
| Label | useSelectLabelRecipe() | A .select-label uppercase heading for grouping options |
| Separator | useSelectSeparatorRecipe() | 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">×</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).
Accessibility
- Wire the ARIA combobox pattern. The trigger should carry
role="combobox",aria-haspopup="listbox", andaria-expanded; the panelrole="listbox"; and each optionrole="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-selectedandaria-disabledon options so the visuals and the accessibility tree stay in sync. The selected tint and check indicator key offaria-selected="true". - Label each remove button. Give every
.select-chip-removeanaria-labelthat 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 byaria-expanded. - Verify contrast ratios. The
darkcolor 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:
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:
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;
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:
| Parameter | Type | Description |
|---|---|---|
s | Styleframe | The Styleframe instance |
options | DeepPartial<RecipeConfig> | Optional overrides for the recipe configuration |
options.base | VariantDeclarationsBlock | Custom base styles for the trigger |
options.variants | Variants | Custom variant definitions for the recipe |
options.defaultVariants | Record<keyof Variants, string> | Default variant values for the recipe |
options.compoundVariants | CompoundVariant[] | Custom compound variant definitions for the recipe |
options.filter | Record<string, string[]> | Limit which variant values are generated |
Variants:
| Variant | Options | Default |
|---|---|---|
color | light, dark, neutral | neutral |
variant | solid, soft, ghost | solid |
size | sm, md, lg | md |
invalid | true, false | false |
disabled | true, false | false |
readonly | true, false | false |
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:
| Variant | Options | Default |
|---|---|---|
color | light, dark, neutral | neutral |
variant | solid, soft, subtle | solid |
size | sm, md, lg | md |
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:
| Variant | Options | Default |
|---|---|---|
color | light, dark, neutral | neutral |
variant | solid, soft, subtle | solid |
size | sm, md, lg | md |
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:
| Variant | Options | Default |
|---|---|---|
color | light, dark, neutral | neutral |
variant | solid, outline, soft, subtle | soft |
size | sm, md, lg | sm |
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:
| Variant | Options | Default |
|---|---|---|
size | sm, md, lg | md |
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:
| Variant | Options | Default |
|---|---|---|
color | light, dark, neutral | neutral |
size | sm, md, lg | md |
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:
| Variant | Options | Default |
|---|---|---|
color | light, dark, neutral | neutral |
Best Practices
- Pass
colorandsizeconsistently: Spread the samecolorandsizeto every part so the whole control scales and themes together. - Use the right variant per part:
solid/soft/ghostfor the trigger,solid/soft/subtlefor the panel and options, and the badge vocabulary for chips. - Drive option state with ARIA: Set
aria-selectedandaria-disabledon options instead of toggling classes by hand. - Label remove buttons: Give each
.select-chip-removeanaria-labelthat names the value it dismisses. - Use
neutralfor 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
filteroption to reduce generated CSS.
FAQ
solid (Input itself uses default), so the trigger axis is solid, soft, ghost; the panel and options mirror the Dropdown recipe (solid, soft, subtle). Pass solid or soft to keep both vocabularies aligned; use ghost on the trigger with subtle on the panel for a borderless look..select-chip for each selected value inside the trigger instead of plain text. Each chip carries a nested .select-chip-remove button for dismissal. The trigger wraps its contents, so chips flow onto new rows as the selection grows. See the Multi-selection section.aria-selected="true" applies a tinted background, a medium font weight, and reveals the leading .select-option-check indicator. aria-disabled="true" dims the row and blocks interaction. Setting the attribute keeps styling and accessibility semantics in sync from a single source..select-arrow recipe registers two rotation selectors in its setup callback: one for .select-arrow[data-open="true"] and one for .select.-open .select-arrow. Add the -open class to the trigger (or data-open="true" to the arrow) when the panel opens, and the chevron flips 180° with a transition.light, dark, and neutral for surface variations that work across all forms; error communication is handled by the trigger's dedicated invalid state.@color.white, @border-radius.md, and @color.primary through string refs. These tokens need to be defined in your Styleframe instance for the recipes to generate valid CSS. The easiest way is useDesignTokensPreset(s), but you can also define the required tokens manually.Radio Group
A layout component for arranging a set of radios with shared orientation and spacing. Supports vertical and horizontal layouts and three gap sizes through the recipe system.
Slider
An input control for selecting a value from a range by dragging a thumb along a track. Composed of a root, track, range, and thumb recipe, with color, size, and orientation options through the recipe system.