Composables

Dropdown

A menu-style floating panel for presenting actions or navigation options. Composed of five coordinated recipes (panel, item, separator, label, arrow) with three colors, three visual styles, and three sizes through the recipe system.

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:

src/components/dropdown.styleframe.ts
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:

src/components/Dropdown.tsx
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 })} />;
}

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

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 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

SizePanel PaddingPanel RadiusItem 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
Good to know: The 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:

PartRecipeRole
PaneluseDropdownRecipe()Outer wrapper with background, border, border radius, shadow, and z-index: @z-index.dropdown
ItemuseDropdownItemRecipe()Clickable menu option with hover, focus, active, and disabled states
SeparatoruseDropdownSeparatorRecipe()Horizontal divider between item groups
LabeluseDropdownLabelRecipe()Uppercase group heading with muted text color
ArrowuseDropdownArrowRecipe()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>
Pro tip: You don't have to use all five parts. A dropdown with only items is valid for simple action lists. Add labels when you want to name an item group, separators when you want to break groups apart visually, and the arrow when your positioning approach points back to a trigger.

Accessibility

  • Use role="menu" on the panel and role="menuitem" on each item. The panel's role="menu" and each item's role="menuitem" give assistive technologies the semantic meaning they need. The separator uses role="separator" and the label uses role="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-expanded toggling between "true" and "false", and aria-controls pointing 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 / ArrowDown to move between items, Home / End to jump to the first / last item, Enter or Space to activate the focused item, and Escape to 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 use tabindex="-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 &:disabled styles apply cursor: not-allowed, opacity: 0.75, and pointer-events: none.
  • Verify contrast ratios. The solid variant with dark color 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:

src/components/dropdown.styleframe.ts
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:

src/components/dropdown.styleframe.ts
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;
Good to know: Filtering also removes compound variants and adjusts default variants that reference filtered-out values, so your recipe stays consistent.

API Reference

useDropdownRecipe(s, options?)

Creates the dropdown panel recipe with background, border, border radius, shadow, and z-index styling.

Parameters:

ParameterTypeDescription
sStyleframeThe Styleframe instance
optionsDeepPartial<RecipeConfig>Optional overrides for the recipe configuration
options.baseVariantDeclarationsBlockCustom base styles for the dropdown panel
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, subtlesolid
sizesm, md, lgmd

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:

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

useDropdownSeparatorRecipe(s, options?)

Creates the dropdown separator recipe for visual dividers between item groups. Accepts only the color axis.

Variants:

VariantOptionsDefault
colorlight, dark, neutralneutral

useDropdownLabelRecipe(s, options?)

Creates the dropdown label recipe for uppercase group headings with muted text color. Accepts color and size but not variant.

Variants:

VariantOptionsDefault
colorlight, dark, neutralneutral
sizesm, md, lgmd

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:

VariantOptionsDefault
colorlight, dark, neutralneutral
variantsolid, soft, subtlesolid

Learn more about recipes →

Best Practices

  • Pass color and variant consistently: The panel and each item need the same color and variant values so that item hover and focus backgrounds render correctly against the surrounding surface.
  • Pass size to 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 the size prop.
  • Use neutral for general-purpose menus: The neutral color adapts to light and dark mode automatically, making it the safest default.
  • Prefer solid for primary menus: Reserve soft and subtle for secondary or nested menus to create visual hierarchy.
  • Group related items and separate unrelated ones: Use DropdownLabel to name a group of related actions, and DropdownSeparator to 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 filter option to reduce generated CSS.
  • Override defaults at the recipe level: Set your most common variant combination as defaultVariants so component consumers write less code.

FAQ