Popover
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:
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:
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>
);
}
<script setup lang="ts">
import { computed } from "vue";
import { popover, popoverHeader, popoverBody, popoverFooter, popoverArrow } from "virtual:styleframe";
const props = withDefaults(
defineProps<{
color?: "light" | "dark" | "neutral";
variant?: "solid" | "soft" | "subtle";
size?: "sm" | "md" | "lg";
}>(),
{},
);
const classes = computed(() => popover({ color: props.color, variant: props.variant, size: props.size }));
const headerClasses = computed(() => popoverHeader({ color: props.color, variant: props.variant, size: props.size }));
const bodyClasses = computed(() => popoverBody({ size: props.size }));
const footerClasses = computed(() => popoverFooter({ color: props.color, variant: props.variant, size: props.size }));
const arrowClasses = computed(() => popoverArrow({ color: props.color, variant: props.variant }));
</script>
<template>
<div class="popover-wrapper">
<div :class="classes">
<div :class="headerClasses">
<slot name="header" />
</div>
<div :class="bodyClasses">
<slot />
</div>
<div :class="footerClasses">
<slot name="footer" />
</div>
</div>
<span :class="arrowClasses" />
</div>
</template>
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
| 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 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
| Size | Border Radius | Header/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 |
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:
| Part | Recipe | Role |
|---|---|---|
| Container | usePopoverRecipe() | Outer wrapper with background, border, border radius, shadow, and position: relative |
| Header | usePopoverHeaderRecipe() | Top section with separator borders |
| Body | usePopoverBodyRecipe() | Main content area with vertical flex layout |
| Footer | usePopoverFooterRecipe() | Bottom section with separator borders |
| Arrow | usePopoverArrowRecipe() | 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>
Accessibility
- Use
aria-haspopupandaria-expandedon the trigger. The trigger element needsaria-haspopup="dialog"andaria-expandedtoggling 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. Userole="dialog"for rich content popovers, orrole="menu"for action lists. Addaria-labelledbypointing 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
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 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:
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:
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;
API Reference
usePopoverRecipe(s, options?)
Creates the popover container recipe with background, border, border radius, shadow, and position: relative 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 popover container |
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 |
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:
| Variant | Options | Default |
|---|---|---|
color | light, dark, neutral | neutral |
variant | solid, soft, subtle | solid |
size | sm, md, lg | md |
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:
| Variant | Options | Default |
|---|---|---|
color | light, dark, neutral | neutral |
variant | solid, soft, subtle | solid |
size | sm, md, lg | md |
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:
| Variant | Options | Default |
|---|---|---|
color | light, dark, neutral | neutral |
variant | solid, soft, subtle | solid |
size | sm, md, lg | md |
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:
| 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 popover arrow |
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 |
Best Practices
- Pass
colorandvariantconsistently: The container, header, footer, and arrow all need the samecolorandvariantvalues so that separator borders and arrow colors match the popover's visual style. - Pass
sizeto 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 thesizeprop. - The arrow only needs
colorandvariant: Do not passsizeto the arrow. Its dimensions come from the@popover.arrow.sizeCSS variable. - Use
neutralfor general-purpose popovers: The neutral color adapts to light and dark mode automatically, making it the safest default. - Prefer
solidfor primary popovers: Reservesoftandsubtlefor 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
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
usePopoverRecipe() with usePopoverBodyRecipe() for content. Add the header and footer recipes only when your design calls for separated sections with visible dividers. The arrow is optional — omit it if your design doesn't need a directional indicator.role="dialog". Tooltips are simple text labels triggered by hover or focus, using role="tooltip". Popovers default to neutral color; tooltips default to dark. The popover arrow is 6px by default; the tooltip arrow is 5px.position: relative, box-shadow: md, and an optional directional arrow. Cards are inline content containers with box-shadow: sm and no arrow. Popovers are typically used with a positioning library for dynamic placement.light, dark, and neutral to provide surface variations that work across all content types without implying a specific status.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 popover to blend naturally with the surrounding interface.subtle also adds a matching border, giving the popover more visual definition. Use soft when you want a borderless, gentler appearance, and subtle when the popover needs slightly more structure.@popover.arrow.size CSS variable (default 6px) rather than discrete size variants. This gives you continuous control over the arrow dimensions and avoids coupling the arrow size to the popover content size. Override the variable to change the arrow size: s.variable('popover.arrow.size', '8px');@popover.arrow.size CSS variable with a default value of 6px. Override it by calling s.variable('popover.arrow.size', '8px') after registering the recipe, or by targeting the variable in your CSS. Both the outer border (used for the border color) and the inner pseudo-element (used for the background fill) reference this variable.The Popover recipe uses compound variants to map each color-variant combination to specific styles. For the 3 colors and 3 variants, 9 compound variant entries define the background, text color, and border color — each with dark mode overrides via the &:dark selector. The header and footer recipes use compound variants to set separator border colors that match the popover's visual style. The arrow recipe uses matching compound variants so its border-triangle colors align with the content container.
@color.white, @border-radius.md, and @box-shadow.md through string refs. These tokens need to be defined in your Styleframe instance for the recipe to generate valid CSS. The easiest way is to use useDesignTokensPreset(s), but you can also define the required tokens manually.Progress
A progress indicator component with a track and fill bar. Supports multiple colors, sizes, orientations, inverted fill direction, and indeterminate animations through the recipe system.
Chip
A status indicator component positioned at the corner of an element. Supports multiple colors, visual styles, sizes, positions, and inset mode through the recipe system.