Dropdown
Overview
The Dropdown is a menu-shaped floating surface that groups clickable actions, navigation links, or option lists. It is composed of five recipe parts: useDropdownRecipe() for the panel container, useDropdownItemRecipe() for clickable menu options, useDropdownSeparatorRecipe() for visual dividers between groups, useDropdownLabelRecipe() for group headings, and useDropdownArrowRecipe() for an optional directional indicator pointing back to the trigger. Each composable creates a fully configured recipe with color, variant (where applicable), and size options — plus compound variants that handle the color-variant combinations and interactive states automatically.
The Dropdown 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 Dropdown recipe?
The Dropdown recipe helps you:
- Ship faster with sensible defaults: Get 3 colors, 3 visual styles, and 3 sizes out of the box with a single set of composable calls.
- Compose menu layouts: Five coordinated recipes (panel, item, separator, label, arrow) share the same color and variant axes, so your menus stay internally consistent.
- Maintain consistency: Compound variants ensure every color-variant combination follows the same design rules, including hover, focus, and active states plus dark mode overrides.
- 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 Dropdown 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 {
useDropdownRecipe,
useDropdownItemRecipe,
useDropdownSeparatorRecipe,
useDropdownLabelRecipe,
useDropdownArrowRecipe,
} from '@styleframe/theme';
const s = styleframe();
const dropdown = useDropdownRecipe(s);
const dropdownItem = useDropdownItemRecipe(s);
const dropdownSeparator = useDropdownSeparatorRecipe(s);
const dropdownLabel = useDropdownLabelRecipe(s);
const dropdownArrow = useDropdownArrowRecipe(s);
export default s;
Build the component
Import the dropdown, dropdownItem, dropdownSeparator, dropdownLabel, and dropdownArrow runtime functions from the virtual module and pass variant props to compute class names:
import {
dropdown,
dropdownItem,
dropdownSeparator,
dropdownLabel,
dropdownArrow,
} from "virtual:styleframe";
interface DropdownProps {
color?: "light" | "dark" | "neutral";
variant?: "solid" | "soft" | "subtle";
size?: "sm" | "md" | "lg";
children?: React.ReactNode;
}
export function Dropdown({
color = "neutral",
variant = "solid",
size = "md",
children,
}: DropdownProps) {
return (
<div role="menu" className={dropdown({ color, variant, size })}>
{children}
</div>
);
}
interface DropdownItemProps extends DropdownProps {
disabled?: boolean;
onClick?: () => void;
}
export function DropdownItem({
color = "neutral",
variant = "solid",
size = "md",
disabled,
onClick,
children,
}: DropdownItemProps) {
return (
<button
type="button"
role="menuitem"
disabled={disabled}
onClick={onClick}
className={dropdownItem({ color, variant, size })}
>
{children}
</button>
);
}
export function DropdownSeparator({ color = "neutral" }: { color?: DropdownProps["color"] }) {
return <hr role="separator" className={dropdownSeparator({ color })} />;
}
export function DropdownLabel({
color = "neutral",
size = "md",
children,
}: Omit<DropdownProps, "variant">) {
return (
<div role="presentation" className={dropdownLabel({ color, size })}>
{children}
</div>
);
}
export function DropdownArrow({
color = "neutral",
variant = "solid",
}: Omit<DropdownProps, "size" | "children">) {
return <div className={dropdownArrow({ color, variant })} />;
}
<script setup lang="ts">
import {
dropdown,
dropdownItem,
dropdownSeparator,
dropdownLabel,
dropdownArrow,
} from "virtual:styleframe";
const {
color = "neutral",
variant = "solid",
size = "md",
} = defineProps<{
color?: "light" | "dark" | "neutral";
variant?: "solid" | "soft" | "subtle";
size?: "sm" | "md" | "lg";
}>();
</script>
<template>
<div role="menu" :class="dropdown({ color, variant, size })">
<div role="presentation" :class="dropdownLabel({ color, size })">
Account
</div>
<button type="button" role="menuitem" :class="dropdownItem({ color, variant, size })">
Profile
</button>
<button type="button" role="menuitem" :class="dropdownItem({ color, variant, size })">
Settings
</button>
<hr role="separator" :class="dropdownSeparator({ color })" />
<button type="button" role="menuitem" :class="dropdownItem({ color, variant, size })">
Sign out
</button>
</div>
</template>
See it in action
Colors
The Dropdown recipe includes 3 color variants: light, dark, and neutral. Like other surface recipes (Card, Modal, Popover), the Dropdown uses neutral-spectrum colors designed for content surfaces rather than status communication. Each color is combined with every visual style variant through compound variants, so you get consistent, predictable styling across all combinations — including hover, focus, and active states plus dark mode overrides.
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 menus.
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 dropdown color. It adapts automatically to the user's color scheme, so you don't need to manage light and dark variants separately.Variants
Three visual style variants control how the dropdown panel and its items are rendered. Each variant is combined with the selected color through compound variants, so you always get the correct surface background, text color, border color, and interactive states for your chosen color. Pass the same variant to both the container and each DropdownItem so item hover and focus backgrounds match the surrounding surface.
Solid
Filled panel with a subtle border. The most prominent style, ideal for primary menus and authoritative actions.
Soft
Light tinted panel with no visible border. A gentle, borderless style that works well for menus embedded in dense layouts.
Subtle
Light tinted panel with a matching border. Combines the softness of the soft variant with added visual definition from a border.
Sizes
Three size variants from sm to lg control the panel's inner padding and border radius, each item's padding and font size, and the label's padding and font size.
Size Reference
| Size | Panel Padding | Panel Radius | Item Padding (V / H) | Item Font Size |
|---|---|---|---|---|
sm | @0.125 | @border-radius.sm | @0.25 / @0.5 | @font-size.xs |
md | @0.25 | @border-radius.md | @0.375 / @0.625 | @font-size.sm |
lg | @0.375 | @border-radius.lg | @0.5 / @0.75 | @font-size.md |
size prop must be passed to the container, each item, and the label individually. The separator does not have a size variant — it's always a single-pixel rule regardless of size.Anatomy
The Dropdown recipe is composed of five independent recipes that work together to form a menu surface:
| Part | Recipe | Role |
|---|---|---|
| Panel | useDropdownRecipe() | Outer wrapper with background, border, border radius, shadow, and z-index: @z-index.dropdown |
| Item | useDropdownItemRecipe() | Clickable menu option with hover, focus, active, and disabled states |
| Separator | useDropdownSeparatorRecipe() | Horizontal divider between item groups |
| Label | useDropdownLabelRecipe() | Uppercase group heading with muted text color |
| Arrow | useDropdownArrowRecipe() | Optional upward-pointing directional indicator using the CSS border-triangle technique |
The color and variant props should be passed consistently to the panel, each item, and the arrow so that item hover and focus backgrounds and the arrow's fill and border all render correctly against the surrounding surface. The separator accepts only color; the label accepts color and size but not variant, since labels are text-only and don't need surface styling. The arrow accepts color and variant but not size — its dimensions come from the @dropdown.arrow.size CSS variable (default: 6px).
<!-- Panel with an arrow wrapped together. The arrow sits above the panel
by default (positioned with top: calc(-size)), since dropdown menus
conventionally open below their trigger. Place the arrow AFTER the
panel in the DOM so it paints on top of the panel's top border. -->
<div class="dropdown-wrapper">
<div role="menu" class="dropdown(...)">
<div role="presentation" class="dropdownLabel(...)">Account</div>
<button type="button" role="menuitem" class="dropdownItem(...)">Profile</button>
<button type="button" role="menuitem" class="dropdownItem(...)">Settings</button>
<hr role="separator" class="dropdownSeparator(...)" />
<button type="button" role="menuitem" class="dropdownItem(...)">Sign out</button>
</div>
<span class="dropdownArrow(...)" />
</div>
Accessibility
- Use
role="menu"on the panel androle="menuitem"on each item. The panel'srole="menu"and each item'srole="menuitem"give assistive technologies the semantic meaning they need. The separator usesrole="separator"and the label usesrole="presentation"to stay out of the item tab order.
<!-- Correct: menu with labelled group and separated action -->
<div role="menu" aria-labelledby="menu-trigger-1" class="...">
<div role="presentation" class="...">Account</div>
<button type="button" role="menuitem" class="...">Profile</button>
<hr role="separator" class="..." />
<button type="button" role="menuitem" class="...">Sign out</button>
</div>
- Wire up the trigger. The triggering button needs
aria-haspopup="menu",aria-expandedtoggling between"true"and"false", andaria-controlspointing to the menu's id. Clicking or pressing Enter/Space on the trigger should open the menu; Escape should close it.
<!-- Correct: trigger with menu ARIA attributes -->
<button aria-haspopup="menu" aria-expanded="false" aria-controls="dropdown-1">
Open menu
</button>
- Implement keyboard navigation. The menu must support
ArrowUp/ArrowDownto move between items,Home/Endto jump to the first / last item,EnterorSpaceto activate the focused item, andEscapeto close the menu and return focus to the trigger. - Use roving tabindex on items. Only one item should be in the tab order at a time (
tabindex="0"); the rest usetabindex="-1"and receive focus programmatically via arrow keys. - Keep disabled items focusable. Disabled items should retain
aria-disabled="true"and remain focusable so keyboard users can perceive them, but not activatable. The recipe's&:disabledstyles applycursor: not-allowed,opacity: 0.75, andpointer-events: none. - Verify contrast ratios. The
solidvariant withdarkcolor places light text on a dark background. Default tokens meet WCAG AA 4.5:1 contrast. If you override colors, verify with the WebAIM Contrast Checker.
Customization
Overriding Defaults
Each dropdown 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 the properties you want to change:
import { styleframe } from 'virtual:styleframe';
import {
useDropdownRecipe,
useDropdownItemRecipe,
useDropdownSeparatorRecipe,
useDropdownLabelRecipe,
useDropdownArrowRecipe,
} from '@styleframe/theme';
const s = styleframe();
const dropdown = useDropdownRecipe(s, {
base: {
boxShadow: '@box-shadow.lg',
minWidth: '@14',
},
defaultVariants: {
color: 'neutral',
variant: 'subtle',
size: 'lg',
},
});
const dropdownItem = useDropdownItemRecipe(s, {
defaultVariants: {
color: 'neutral',
variant: 'subtle',
size: 'lg',
},
});
const dropdownSeparator = useDropdownSeparatorRecipe(s, {
defaultVariants: {
color: 'neutral',
},
});
const dropdownLabel = useDropdownLabelRecipe(s, {
defaultVariants: {
color: 'neutral',
size: 'lg',
},
});
const dropdownArrow = useDropdownArrowRecipe(s, {
defaultVariants: {
color: 'neutral',
variant: 'subtle',
},
});
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 { useDropdownRecipe, useDropdownItemRecipe } from '@styleframe/theme';
const s = styleframe();
// Only generate neutral color with solid and subtle styles
const dropdown = useDropdownRecipe(s, {
filter: {
color: ['neutral'],
variant: ['solid', 'subtle'],
},
});
const dropdownItem = useDropdownItemRecipe(s, {
filter: {
color: ['neutral'],
variant: ['solid', 'subtle'],
},
});
export default s;
API Reference
useDropdownRecipe(s, options?)
Creates the dropdown panel recipe with background, border, border radius, shadow, and z-index styling.
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 dropdown panel |
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, subtle | solid |
size | sm, md, lg | md |
useDropdownItemRecipe(s, options?)
Creates the dropdown item recipe with hover, focus, active, and disabled states. Accepts the same parameters and variant axes as useDropdownRecipe.
Variants:
| Variant | Options | Default |
|---|---|---|
color | light, dark, neutral | neutral |
variant | solid, soft, subtle | solid |
size | sm, md, lg | md |
useDropdownSeparatorRecipe(s, options?)
Creates the dropdown separator recipe for visual dividers between item groups. Accepts only the color axis.
Variants:
| Variant | Options | Default |
|---|---|---|
color | light, dark, neutral | neutral |
useDropdownLabelRecipe(s, options?)
Creates the dropdown label recipe for uppercase group headings with muted text color. Accepts color and size but not variant.
Variants:
| Variant | Options | Default |
|---|---|---|
color | light, dark, neutral | neutral |
size | sm, md, lg | md |
useDropdownArrowRecipe(s, options?)
Creates the dropdown arrow recipe using the CSS border-triangle technique. Points upward by default so it sits above the panel, matching the common layout where the menu opens below its trigger. Accepts color and variant. Its dimensions come from the @dropdown.arrow.size CSS variable (default: 6px), which the recipe's setup callback registers when you call the composable.
Variants:
| Variant | Options | Default |
|---|---|---|
color | light, dark, neutral | neutral |
variant | solid, soft, subtle | solid |
Best Practices
- Pass
colorandvariantconsistently: The panel and each item need the samecolorandvariantvalues so that item hover and focus backgrounds render correctly against the surrounding surface. - Pass
sizeto every sub-recipe that accepts it: The panel controls its own padding and border radius, and each item and label manages its own padding and font size based on thesizeprop. - Use
neutralfor general-purpose menus: The neutral color adapts to light and dark mode automatically, making it the safest default. - Prefer
solidfor primary menus: Reservesoftandsubtlefor secondary or nested menus to create visual hierarchy. - Group related items and separate unrelated ones: Use
DropdownLabelto name a group of related actions, andDropdownSeparatorto break apart groups with distinct purposes. - Keep destructive actions at the bottom: Actions like "Delete" or "Sign out" are conventional at the end of a menu, often preceded by a separator.
- Filter what you don't need: If your component only uses one color, pass a
filteroption to reduce generated CSS. - Override defaults at the recipe level: Set your most common variant combination as
defaultVariantsso component consumers write less code.
FAQ
useDropdownRecipe() with a few useDropdownItemRecipe() items inside. Add useDropdownLabelRecipe() when your menu has grouped actions, useDropdownSeparatorRecipe() when those groups need visual separation, and useDropdownArrowRecipe() when your positioning approach points back to a trigger.light, dark, and neutral to provide surface variations that work across all menu contexts without implying a specific status. For a destructive action like "Delete", style the action's icon or text rather than the entire menu.light always uses white and gray-100 backgrounds regardless of the color scheme. dark always uses gray-800 and gray-900 backgrounds. neutral adapts to the current color scheme: it appears light in light mode and dark in dark mode. Use neutral when you want the dropdown to blend naturally with the surrounding interface.The Dropdown and DropdownItem recipes each use 9 compound variants (3 colors × 3 variants) to map each color-variant combination to specific styles. For example, when color is neutral and variant is solid, the panel's compound variant applies background: @color.white, color: @color.text, and borderColor: @color.gray-200, along with dark mode overrides. The item's matching compound variant defines hover, focus, active, and dark-mode interactive backgrounds so the item highlight matches the panel surface. The Separator and Label recipes use 3 compound variants each (one per color).
filter option, compound variants that reference filtered-out values are automatically removed. For example, if you filter variant to only ['solid', 'subtle'], all compound variants matching soft are excluded from the generated output. Default variants are also adjusted if they reference a removed value.Spinner
A loading spinner component with color, size, and optional overlay — built as a multi-part recipe system with SVG-based animation.
Overview
Explore Styleframe's comprehensive design token system. Create consistent, scalable design systems with composable functions for colors, typography, spacing, and more.