Composables

Popover

A floating container component with structured sections and a directional arrow for contextual content. Supports multiple colors, visual styles, and sizes through the recipe system.

Overview

The Popover is a floating container element used for contextual content triggered by user interaction. It is composed of five recipe parts: usePopoverRecipe() for the container, usePopoverHeaderRecipe() for the top section with a separator, usePopoverBodyRecipe() for the main content area, usePopoverFooterRecipe() for the bottom section with a separator, and usePopoverArrowRecipe() for the directional arrow. Each composable creates a fully configured recipe with color, variant, and size options — plus compound variants that handle the color-variant combinations automatically.

The Popover combines the structured layout of a Card (header, body, footer sections) with the directional arrow of a Tooltip. The 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 Popover recipe?

The Popover recipe helps you:

  • Ship faster with sensible defaults: Get 3 colors, 3 visual styles, and 3 sizes out of the box with five composable calls.
  • Compose structured floating layouts: Five coordinated recipes (container, header, body, footer, arrow) share the same variant axes, so your popovers stay internally consistent.
  • Maintain consistency: Compound variants ensure every color-variant combination follows the same design rules, including separator colors, arrow colors, and 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 Popover 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/popover.styleframe.ts
import { styleframe } from 'virtual:styleframe';
import {
    usePopoverRecipe,
    usePopoverHeaderRecipe,
    usePopoverBodyRecipe,
    usePopoverFooterRecipe,
    usePopoverArrowRecipe,
} from '@styleframe/theme';

const s = styleframe();

const popover = usePopoverRecipe(s);
const popoverHeader = usePopoverHeaderRecipe(s);
const popoverBody = usePopoverBodyRecipe(s);
const popoverFooter = usePopoverFooterRecipe(s);
const popoverArrow = usePopoverArrowRecipe(s);

export default s;

Build the component

Import the popover, popoverHeader, popoverBody, popoverFooter, and popoverArrow runtime functions from the virtual module and pass variant props to compute class names:

src/components/Popover.tsx
import { popover, popoverHeader, popoverBody, popoverFooter, popoverArrow } from "virtual:styleframe";

interface PopoverProps {
    color?: "light" | "dark" | "neutral";
    variant?: "solid" | "soft" | "subtle";
    size?: "sm" | "md" | "lg";
    title?: string;
    children?: React.ReactNode;
    footer?: React.ReactNode;
}

export function Popover({
    color = "neutral",
    variant = "solid",
    size = "md",
    title,
    children,
    footer,
}: PopoverProps) {
    return (
        <div className="popover-wrapper">
            <div className={popover({ color, variant, size })}>
                {title && (
                    <div className={popoverHeader({ color, variant, size })}>
                        <strong>{title}</strong>
                    </div>
                )}
                <div className={popoverBody({ size })}>
                    {children}
                </div>
                {footer && (
                    <div className={popoverFooter({ color, variant, size })}>
                        {footer}
                    </div>
                )}
            </div>
            <span className={popoverArrow({ color, variant })} />
        </div>
    );
}

See it in action

Colors

The Popover recipe includes 3 color variants: light, dark, and neutral. Like the Card recipe, the Popover 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 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 popovers.

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 popover 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 popover is rendered. Each variant is combined with the selected color through compound variants, so you always get the correct background, text, border, separator, and arrow colors for your chosen color.

Solid

Filled background with a subtle border. The most prominent style, ideal for primary floating content and featured interactions.

Soft

Light tinted background with no visible border. A gentle, borderless style that works well for popovers in dense layouts.

Subtle

Light tinted background 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 border radius of the popover container and the padding and gap of the header, body, and footer sections.

Size Reference

SizeBorder RadiusHeader/Footer Padding (V / H)Body Padding (V / H)Gap
sm@border-radius.sm@0.5 / @0.75@0.5 / @0.75@0.375@0.5
md@border-radius.md@0.75 / @1@0.75 / @1@0.5@0.75
lg@border-radius.lg@1 / @1.25@1 / @1.25@0.75@1
Good to know: The size prop must be passed to the container, header, body, and footer individually. The arrow recipe does not have a size variant — its dimensions are controlled by the @popover.arrow.size CSS variable (default: 6px).

Anatomy

The Popover recipe is composed of five independent recipes that work together to form a structured floating layout:

PartRecipeRole
ContainerusePopoverRecipe()Outer wrapper with background, border, border radius, shadow, and position: relative
HeaderusePopoverHeaderRecipe()Top section with separator borders
BodyusePopoverBodyRecipe()Main content area with vertical flex layout
FooterusePopoverFooterRecipe()Bottom section with separator borders
ArrowusePopoverArrowRecipe()Directional indicator using CSS border-triangle technique

The color and variant props should be passed consistently to the container, header, footer, and arrow so that separator borders and arrow colors match the popover's visual style. The body recipe only requires a size prop for padding. The arrow recipe accepts color and variant but not size — its dimensions come from the @popover.arrow.size CSS variable.

<!-- All five parts working together -->
<div class="popover-wrapper">
    <div class="popover(...)">
        <div class="popoverHeader(...)">Header content</div>
        <div class="popoverBody(...)">Body content</div>
        <div class="popoverFooter(...)">Footer content</div>
    </div>
    <span class="popoverArrow(...)" />
</div>
Pro tip: You don't have to use all five parts. A popover with only a body is valid for simple floating content. The arrow is optional — omit it if your positioning approach does not require one.

Accessibility

  • Use aria-haspopup and aria-expanded on the trigger. The trigger element needs aria-haspopup="dialog" and aria-expanded toggling between "true" and "false" when the popover opens and closes.
<!-- Correct: trigger with popover ARIA attributes -->
<button aria-haspopup="dialog" aria-expanded="false" aria-controls="popover-1">
    Open popover
</button>
  • Give the popover a role. Use role="dialog" for rich content popovers, or role="menu" for action lists. Add aria-labelledby pointing to the header's title element.
<!-- Correct: popover with dialog role and label -->
<div role="dialog" id="popover-1" aria-labelledby="popover-title-1" class="...">
    <div class="..."><h3 id="popover-title-1">Popover Title</h3></div>
    <div class="..."><p>Popover content</p></div>
</div>
  • Manage focus. When the popover opens, move focus into it (first focusable element or the container with tabindex="-1"). When it closes, return focus to the trigger.
  • Close on Escape. The popover must dismiss when the user presses Escape, returning focus to the trigger (WCAG 1.4.13).
  • Close on outside click. Clicking outside the popover should close it for a predictable interaction pattern.
  • 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 popover 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/popover.styleframe.ts
import { styleframe } from 'virtual:styleframe';
import {
    usePopoverRecipe,
    usePopoverHeaderRecipe,
    usePopoverBodyRecipe,
    usePopoverFooterRecipe,
    usePopoverArrowRecipe,
} from '@styleframe/theme';

const s = styleframe();

const popover = usePopoverRecipe(s, {
    base: {
        boxShadow: '@box-shadow.lg',
    },
    defaultVariants: {
        color: 'neutral',
        variant: 'subtle',
        size: 'lg',
    },
});

const popoverHeader = usePopoverHeaderRecipe(s, {
    defaultVariants: {
        color: 'neutral',
        variant: 'subtle',
        size: 'lg',
    },
});

const popoverBody = usePopoverBodyRecipe(s, {
    defaultVariants: {
        size: 'lg',
    },
});

const popoverFooter = usePopoverFooterRecipe(s, {
    defaultVariants: {
        color: 'neutral',
        variant: 'subtle',
        size: 'lg',
    },
});

const popoverArrow = usePopoverArrowRecipe(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/popover.styleframe.ts
import { styleframe } from 'virtual:styleframe';
import { usePopoverRecipe, usePopoverArrowRecipe } from '@styleframe/theme';

const s = styleframe();

// Only generate neutral color with solid and subtle styles
const popover = usePopoverRecipe(s, {
    filter: {
        color: ['neutral'],
        variant: ['solid', 'subtle'],
    },
});

const popoverArrow = usePopoverArrowRecipe(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

usePopoverRecipe(s, options?)

Creates the popover container recipe with background, border, border radius, shadow, and position: relative styling.

Parameters:

ParameterTypeDescription
sStyleframeThe Styleframe instance
optionsDeepPartial<RecipeConfig>Optional overrides for the recipe configuration
options.baseVariantDeclarationsBlockCustom base styles for the popover 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
colorlight, dark, neutralneutral
variantsolid, soft, subtlesolid
sizesm, md, lgmd

usePopoverHeaderRecipe(s, options?)

Creates the popover header recipe with separator borders. Registers :first-child and :last-child selectors for automatic border collapsing. Accepts the same parameters as usePopoverRecipe.

Variants:

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

usePopoverBodyRecipe(s, options?)

Creates the popover body recipe for the main content area with vertical flex layout. Accepts the same parameters as usePopoverRecipe. The body inherits its color from the container and does not use compound variants.

Variants:

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

usePopoverFooterRecipe(s, options?)

Creates the popover footer recipe with separator borders. Registers :first-child and :last-child selectors for automatic border collapsing. Accepts the same parameters as usePopoverRecipe.

Variants:

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

usePopoverArrowRecipe(s, options?)

Creates the popover arrow recipe using a CSS border-triangle technique with a pseudo-element for the inner fill. Registers the @popover.arrow.size CSS variable (default: 6px).

Parameters:

ParameterTypeDescription
sStyleframeThe Styleframe instance
optionsDeepPartial<RecipeConfig>Optional overrides for the recipe configuration
options.baseVariantDeclarationsBlockCustom base styles for the popover arrow
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

Learn more about recipes →

Best Practices

  • Pass color and variant consistently: The container, header, footer, and arrow all need the same color and variant values so that separator borders and arrow colors match the popover's visual style.
  • Pass size to the container and each section: The container controls the border radius, while the header, body, and footer each manage their own padding and gap based on the size prop.
  • The arrow only needs color and variant: Do not pass size to the arrow. Its dimensions come from the @popover.arrow.size CSS variable.
  • Use neutral for general-purpose popovers: The neutral color adapts to light and dark mode automatically, making it the safest default.
  • Prefer solid for primary popovers: Reserve soft and subtle for secondary or nested popovers to create visual hierarchy.
  • Use a positioning library for placement: The recipe handles visual styling only. Use Floating UI or a similar library for dynamic positioning, collision detection, and arrow placement.
  • Don't use all sections if you don't need them: A popover with only a body is valid for simple floating content. The arrow is optional.
  • 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