Composables

Nav

A navigation component for horizontal and vertical link lists. Supports multiple colors, visual styles, sizes, and active/disabled states through a two-part recipe system.

Overview

The Nav is a navigation component used for building horizontal and vertical link lists such as navbars, sidebars, and tab bars. It is composed of two recipe parts: useNavRecipe() for the container that controls layout direction and spacing, and useNavItemRecipe() for individual navigation links with color, variant, and interactive state options. Each composable creates a fully configured recipe with compound variants that handle the color-variant combinations automatically.

The Nav 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 Nav recipe?

The Nav recipe helps you:

  • Ship faster with sensible defaults: Get 2 orientations, 3 colors, 2 visual styles, and 3 sizes out of the box with a pair of composable calls.
  • Compose flexible layouts: Two coordinated recipes (container + item) share the size axis, so your navigation stays internally consistent.
  • Maintain consistency: Compound variants ensure every color-variant combination follows the same design rules, including hover, focus, active, and dark mode states.
  • 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 Nav 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/nav.styleframe.ts
import { styleframe } from 'virtual:styleframe';
import { useNavRecipe, useNavItemRecipe } from '@styleframe/theme';

const s = styleframe();

const nav = useNavRecipe(s);
const navItem = useNavItemRecipe(s);

export default s;

Build the component

Import the nav and navItem runtime functions from the virtual module and pass variant props to compute class names:

src/components/Nav.tsx
import { nav, navItem } from "virtual:styleframe";

interface NavItemProps {
    color?: "light" | "dark" | "neutral";
    variant?: "ghost" | "link";
    size?: "sm" | "md" | "lg";
    active?: boolean;
    disabled?: boolean;
    href?: string;
    children?: React.ReactNode;
}

interface NavProps {
    orientation?: "horizontal" | "vertical";
    size?: "sm" | "md" | "lg";
    children?: React.ReactNode;
}

export function Nav({
    orientation = "horizontal",
    size = "md",
    children,
}: NavProps) {
    return (
        <nav className={nav({ orientation, size })}>
            {children}
        </nav>
    );
}

export function NavItem({
    color = "neutral",
    variant = "ghost",
    size = "md",
    active = false,
    disabled = false,
    href,
    children,
}: NavItemProps) {
    return (
        <a
            href={href}
            className={navItem({
                color,
                variant,
                size,
                active: active ? "true" : "false",
                disabled: disabled ? "true" : "false",
            })}
            aria-disabled={disabled || undefined}
            tabIndex={disabled ? -1 : undefined}
        >
            {children}
        </a>
    );
}

See it in action

Orientation

The Nav container recipe supports two orientations that control the flex layout direction. Horizontal arranges items in a row (ideal for top navbars and tab bars), while vertical stacks items in a column (ideal for sidebars and dropdown menus).

Orientation Reference

OrientationFlex DirectionAlignmentUse Case
horizontalrowcenterTop-level navbars, tab bars, breadcrumbs
verticalcolumnflex-startSidebars, dropdown menus, stacked navigation
Pro tip: Use horizontal for top-level navigation and vertical for sidebar navigation. The orientation only affects the container — individual items render the same regardless of direction.

Colors

The Nav item recipe includes 3 color variants: light, dark, and neutral. Like the Card recipe, Nav uses neutral-spectrum colors designed for structural navigation elements 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 dark mode overrides.

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

Color Reference

ColorTokenUse Case
light@color.text / @color.gray-*Navigation on light backgrounds, stays light-text in dark mode
dark@color.gray-200Navigation on dark backgrounds, stays dark appearance in light mode
neutralAdaptive (light ↔ dark)Default color, adapts to the current color scheme
Pro tip: Use neutral as your default nav item color. It adapts automatically to the user's color scheme, so you don't need to manage light and dark variants separately.

Variants

Two visual style variants control how nav items are rendered. Each variant is combined with the selected color through compound variants, so you always get the correct text color and hover behavior for your chosen color.

Ghost

Transparent background that reveals a tinted background on hover. The most common style for navigation, ideal for navbars and sidebars where items should be subtle at rest but clearly interactive on hover.

Transparent background with colored text that gains an underline on hover. Use for secondary navigation, inline link lists, or when items should look like standard hyperlinks.

Sizes

Three size variants from sm to lg control the font size, gap, and padding of the navigation. The size prop affects both the container (font size and gap between items) and individual items (font size and padding).

Size Reference

Nav Container:

SizeFont SizeGap
sm@font-size.xs@0.25
md@font-size.sm@0.5
lg@font-size.md@0.75

Nav Item:

SizeFont SizePadding (V / H)
sm@font-size.xs@0.25 / @0.5
md@font-size.sm@0.375 / @0.75
lg@font-size.md@0.5 / @1
Good to know: The size prop must be passed to both the nav container and each nav item individually. The container controls font size and gap between items, while items control their own padding.

Active

The Nav item recipe includes an active boolean variant. Active items receive font-weight: semibold to visually distinguish the current page or section from other navigation links.

// Active item via variant prop
navItem({ color: "neutral", variant: "ghost", size: "md", active: "true" })
Good practice: Always pair the active variant with aria-current="page" so both sighted users and screen reader users know which page is currently active.

Disabled

The Nav item recipe includes a built-in disabled state through two mechanisms: the &:disabled pseudo-class for native <button> elements, and the disabled boolean variant for <a> elements. Both reduce opacity to 0.5, set cursor: not-allowed, and disable pointer events.

// Disabled item via variant prop
navItem({ color: "neutral", variant: "ghost", size: "md", disabled: "true" })

Anatomy

The Nav recipe is composed of two independent recipes that work together to form a navigation layout:

PartRecipeRole
ContaineruseNavRecipe()Outer wrapper with flex layout, orientation, and gap
ItemuseNavItemRecipe()Individual navigation link with color, variant, and interactive states

Each part is a standalone recipe with its own set of variants. The size prop should be passed consistently to both the container and each item so that font sizes and spacing stay coordinated. The color and variant props only apply to items.

<!-- Both parts working together -->
<nav class="nav(...)">
    <a class="navItem(...)">Home</a>
    <a class="navItem({ active: 'true' })" aria-current="page">About</a>
    <a class="navItem({ disabled: 'true' })" aria-disabled="true">Disabled</a>
</nav>
Pro tip: The container recipe controls layout (direction, spacing) while the item recipe controls appearance (color, hover states, active/disabled states). You don't have to use the container — nav items work perfectly well on their own if you manage the layout yourself.

Accessibility

  • Use semantic HTML. Render the container as a <nav> element with an aria-label to identify the navigation landmark. Use <a> elements for items that navigate and <button> for items that trigger actions.
<!-- Correct: semantic nav landmark with label -->
<nav aria-label="Main navigation" class="...">
    <a href="/" class="...">Home</a>
    <a href="/about" class="navItem({ active: 'true' })" aria-current="page">About</a>
</nav>

<!-- Avoid: non-semantic container -->
<div class="...">
    <span class="..." onclick="navigate('/')">Home</span>
</div>
  • Mark the current page with aria-current="page". When a nav item represents the active page, add aria-current="page" alongside the active: "true" variant so screen readers announce it as the current location (WCAG 2.4.8).
  • Focus visibility. The nav item recipe includes a :focus-visible outline (2px solid, primary color, 2px offset) that appears only during keyboard navigation (WCAG 2.4.7).
  • Disabled state. For <a> elements, pass disabled: "true" and add aria-disabled="true" and tabindex="-1" to remove the link from the tab order. The :disabled pseudo-class handles <button> elements automatically.
<!-- Correct: disabled link with ARIA attributes -->
<a href="#" class="navItem({ disabled: 'true' })" aria-disabled="true" tabindex="-1">Disabled</a>

<!-- Correct: disabled button element -->
<button class="..." disabled>Disabled</button>
Good practice: If your page has multiple <nav> elements, give each one a distinct aria-label (e.g., "Main navigation", "Footer navigation") so screen reader users can tell them apart.

Customization

Overriding Defaults

Each nav 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/nav.styleframe.ts
import { styleframe } from 'virtual:styleframe';
import { useNavRecipe, useNavItemRecipe } from '@styleframe/theme';

const s = styleframe();

const nav = useNavRecipe(s, {
    base: {
        gap: '@1',
    },
    defaultVariants: {
        orientation: 'vertical',
        size: 'lg',
    },
});

const navItem = useNavItemRecipe(s, {
    base: {
        borderRadius: '@border-radius.full',
    },
    defaultVariants: {
        color: 'neutral',
        variant: 'ghost',
        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/nav.styleframe.ts
import { styleframe } from 'virtual:styleframe';
import { useNavRecipe, useNavItemRecipe } from '@styleframe/theme';

const s = styleframe();

const nav = useNavRecipe(s);

// Only generate neutral color with ghost style
const navItem = useNavItemRecipe(s, {
    filter: {
        color: ['neutral'],
        variant: ['ghost'],
    },
});

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

useNavRecipe(s, options?)

Creates the nav container recipe with flex layout, orientation, and gap styling.

Parameters:

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

useNavItemRecipe(s, options?)

Creates the nav item recipe with color, variant, interactive states, and active/disabled boolean variants.

Parameters:

ParameterTypeDescription
sStyleframeThe Styleframe instance
optionsDeepPartial<RecipeConfig>Optional overrides for the recipe configuration
options.baseVariantDeclarationsBlockCustom base styles for the nav item
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
variantghost, linkghost
sizesm, md, lgmd
activetrue, falsefalse
disabledtrue, falsefalse

Learn more about recipes →

Best Practices

  • Pass size consistently to both recipes: The container controls gap and font size, while items control their own padding. Mismatched sizes create visual inconsistency.
  • Use neutral for general-purpose navigation: The neutral color adapts to light and dark mode automatically, making it the safest default.
  • Prefer ghost for primary navigation bars: The transparent resting state keeps the nav clean while hover states confirm interactivity. Use link for secondary or inline navigation.
  • Always mark the current page as active: Pass active: "true" alongside aria-current="page" so both visual and assistive technology users know where they are.
  • Use horizontal for top navbars, vertical for sidebars: Match the orientation to the layout context rather than mixing orientations within the same navigation level.
  • Filter what you don't need: If your component only uses one variant, 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