Composables

Modal

A dialog component for focused interactions, composed of overlay, container, header, body, and footer sections. Supports multiple colors, visual styles, and sizes through the recipe system.

Overview

The Modal is a dialog component used for focused interactions that require the user's attention, such as confirmations, forms, and detail views. It is composed of five recipe parts: useModalRecipe() for the container, useModalHeaderRecipe() for the top section with a bottom separator, useModalBodyRecipe() for the main content area, useModalFooterRecipe() for the bottom section with right-aligned actions, and useModalOverlayRecipe() for the full-screen backdrop. Each composable creates a fully configured recipe with color, variant, and size options — plus compound variants that handle the color-variant combinations automatically.

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

The Modal 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 structured layouts: Five coordinated recipes (overlay, container, header, body, footer) share the same variant axes, so your modals stay internally consistent.
  • Maintain consistency: Compound variants ensure every color-variant combination follows the same design rules, including separator 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 Modal 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/modal.styleframe.ts
import { styleframe } from 'virtual:styleframe';
import {
    useModalRecipe,
    useModalHeaderRecipe,
    useModalBodyRecipe,
    useModalFooterRecipe,
    useModalOverlayRecipe,
} from '@styleframe/theme';

const s = styleframe();

const modal = useModalRecipe(s);
const modalHeader = useModalHeaderRecipe(s);
const modalBody = useModalBodyRecipe(s);
const modalFooter = useModalFooterRecipe(s);
const modalOverlay = useModalOverlayRecipe(s);

export default s;

Build the component

Import the modal, modalHeader, modalBody, modalFooter, and modalOverlay runtime functions from the virtual module and pass variant props to compute class names:

src/components/Modal.tsx
import { modal, modalHeader, modalBody, modalFooter, modalOverlay } from "virtual:styleframe";

interface ModalProps {
    color?: "light" | "dark" | "neutral";
    variant?: "solid" | "soft" | "subtle";
    size?: "sm" | "md" | "lg";
    open?: boolean;
    title?: string;
    description?: string;
    footer?: React.ReactNode;
    children?: React.ReactNode;
    onClose?: () => void;
}

export function Modal({
    color = "neutral",
    variant = "solid",
    size = "md",
    open = false,
    title,
    description,
    footer,
    children,
    onClose,
}: ModalProps) {
    if (!open) return null;

    return (
        <div className={modalOverlay()} onClick={(e) => {
            if (e.target === e.currentTarget) onClose?.();
        }}>
            <div
                className={modal({ color, variant, size })}
                role="dialog"
                aria-modal="true"
                aria-labelledby="modal-title"
            >
                {title && (
                    <div className={modalHeader({ color, variant, size })}>
                        <h2 id="modal-title">{title}</h2>
                    </div>
                )}
                <div className={modalBody({ size })}>
                    {description && <p>{description}</p>}
                    {children}
                </div>
                {footer && (
                    <div className={modalFooter({ color, variant, size })}>
                        {footer}
                    </div>
                )}
            </div>
        </div>
    );
}

See it in action

Colors

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

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

Solid

Filled background with a subtle border. The most prominent style, ideal for primary dialogs and focused interactions.

Soft

Light tinted background with no visible border. A gentle, borderless style that works well for informational dialogs and less critical interactions.

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 modal 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 each sub-recipe individually. The modal container controls the border radius, while the header, body, and footer control their own padding and gap.

Anatomy

The Modal recipe is composed of five independent recipes that work together to form a dialog layout:

PartRecipeRole
OverlayuseModalOverlayRecipe()Full-screen backdrop with centered flex layout
ContaineruseModalRecipe()Outer wrapper with background, border, border radius, and shadow
HeaderuseModalHeaderRecipe()Top section with a bottom separator border
BodyuseModalBodyRecipe()Main content area with vertical flex layout
FooteruseModalFooterRecipe()Bottom section with a top separator border and right-aligned actions

Each part is a standalone recipe with its own set of variants. The color and variant props should be passed consistently to the container, header, and footer so that separator border colors match the modal's visual style. The body recipe only requires a size prop for padding. The overlay recipe has no variants — it provides a fixed-position backdrop with a dark semi-transparent background.

<!-- All five parts working together -->
<div class="modalOverlay()">
    <div class="modal(...)" role="dialog" aria-modal="true">
        <div class="modalHeader(...)">Header content</div>
        <div class="modalBody(...)">Body content</div>
        <div class="modalFooter(...)">Footer content</div>
    </div>
</div>
Pro tip: You don't have to use all five parts. A modal with only a body is valid for simple content. Add the header and footer only when your content needs distinct sections. The overlay can be replaced with your own backdrop implementation if you need custom behavior.

Accessibility

  • Use role="dialog" and aria-modal="true". The modal container should have role="dialog" and aria-modal="true" to announce it as a modal dialog to assistive technologies.
<!-- Correct: dialog role with modal semantics -->
<div class="modalOverlay()">
    <div class="modal(...)" role="dialog" aria-modal="true" aria-labelledby="modal-title">
        <div class="modalHeader(...)">
            <h2 id="modal-title">Confirm Action</h2>
        </div>
        <div class="modalBody(...)">
            <p id="modal-desc">Are you sure you want to proceed?</p>
        </div>
        <div class="modalFooter(...)">
            <button>Cancel</button>
            <button>Confirm</button>
        </div>
    </div>
</div>
  • Label the modal. Use aria-labelledby pointing to the modal title element so screen readers announce the dialog's purpose when it opens.
  • Describe the modal (optional). Use aria-describedby pointing to the modal description for additional context about the dialog's content.
  • Trap focus inside the modal. When the modal is open, focus should cycle between focusable elements inside the dialog. The recipe handles visual styling only — implement focus trapping in your component logic.
  • Close on Escape. Add a keydown listener for the Escape key to close the modal. This is a standard expectation for dialog interactions (WCAG 2.1.1).
  • Return focus on close. When the modal closes, return focus to the element that triggered it.

Customization

Overriding Defaults

Each modal 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/modal.styleframe.ts
import { styleframe } from 'virtual:styleframe';
import {
    useModalRecipe,
    useModalHeaderRecipe,
    useModalBodyRecipe,
    useModalFooterRecipe,
    useModalOverlayRecipe,
} from '@styleframe/theme';

const s = styleframe();

const modal = useModalRecipe(s, {
    base: {
        borderRadius: '@border-radius.lg',
        boxShadow: '@box-shadow.md',
    },
    defaultVariants: {
        color: 'neutral',
        variant: 'subtle',
        size: 'lg',
    },
});

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

const modalBody = useModalBodyRecipe(s, {
    defaultVariants: {
        size: 'lg',
    },
});

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

const modalOverlay = useModalOverlayRecipe(s, {
    base: {
        background: 'rgba(0, 0, 0, 0.5)',
    },
});

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/modal.styleframe.ts
import { styleframe } from 'virtual:styleframe';
import { useModalRecipe } from '@styleframe/theme';

const s = styleframe();

// Only generate neutral color with solid and subtle styles
const modal = useModalRecipe(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

useModalRecipe(s, options?)

Creates the modal container recipe with background, border, border radius, and shadow styling.

Parameters:

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

useModalHeaderRecipe(s, options?)

Creates the modal header recipe with a bottom separator border. Accepts the same parameters and variant axes as useModalRecipe. Includes a setup function that hides the top border when the header is the first child and the bottom border when it is the last child.

Variants:

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

useModalBodyRecipe(s, options?)

Creates the modal body recipe for the main content area. Accepts the same parameters as useModalRecipe. The body recipe has no compound variants — it controls only padding and gap through the size axis.

Variants:

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

useModalFooterRecipe(s, options?)

Creates the modal footer recipe with a top separator border and right-aligned content (justifyContent: "flex-end"). Accepts the same parameters and variant axes as useModalRecipe. Includes a setup function that hides the top border when the footer is the first child and the bottom border when it is the last child.

Variants:

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

useModalOverlayRecipe(s, options?)

Creates the modal overlay recipe for the full-screen backdrop. The overlay has no variants — it provides a fixed-position, centered flex container with a dark semi-transparent background (rgba(0, 0, 0, 0.75)) and a z-index of 1000.

Parameters:

ParameterTypeDescription
sStyleframeThe Styleframe instance
optionsDeepPartial<RecipeConfig>Optional overrides for the recipe configuration
options.baseVariantDeclarationsBlockCustom base styles for the overlay

Learn more about recipes →

Best Practices

  • Pass color and variant consistently: The container, header, and footer all need the same color and variant values so that separator borders match the modal's visual style.
  • Pass size to each sub-recipe: The modal container controls the border radius, but each section (header, body, footer) manages its own padding and gap based on the size prop.
  • Use neutral for general-purpose modals: The neutral color adapts to light and dark mode automatically, making it the safest default.
  • Prefer solid for primary dialogs: Reserve soft and subtle for informational or secondary modals to create visual hierarchy.
  • Don't use all sections if you don't need them: A modal with only a body is valid. Add headers and footers only when your content has distinct sections.
  • Implement focus trapping in your component: The recipe handles styling only. Use a focus trap library or custom logic to keep focus inside the modal while it is open.
  • Close on backdrop click and Escape: Use @click.self on the overlay to close when clicking the backdrop, and listen for the Escape key.
  • 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