Composables

Media

A flexible layout primitive that places visual content alongside text content. Built for comments, social posts, list items, and any UI where a fixed-size visual sits next to flowing text.

Overview

The Media is a layout primitive used to align visual content (image, avatar, icon) with adjacent text content — the canonical "media object" pattern popularized by Bootstrap. It is composed of four recipe parts: useMediaRecipe() for the flex container, useMediaFigureRecipe() for the visual holder, useMediaBodyRecipe() for the text container, and useMediaTitleRecipe() for the heading. Each composable creates a fully configured recipe with its own variant axes — no color or surface styling, so the Media composes cleanly inside other containers like Card or Callout.

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

The Media recipe helps you:

  • Ship the canonical media object pattern: Get the image-plus-text layout that powers comments, posts, notifications, and list items out of the box.
  • Control orientation and alignment declaratively: Switch between horizontal and vertical layouts, and align the figure to the start, center, or end of the body, all through props.
  • Scale typography and spacing together: Pass a single size prop to each part to keep figure radius, body gap, and title font-size in lockstep.
  • Compose inside other recipes: Layout-only with no color or background means the Media slots into Cards, Callouts, or any container without conflicting styles.
  • Stay type-safe: Full TypeScript support means your editor catches invalid orientation, align, or size values at compile time.
  • Customize without forking: Override base styles, default variants, or filter out options you don't need — all through the options API.

Usage

Register the recipes

Add the Media 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/media.styleframe.ts
import { styleframe } from 'virtual:styleframe';
import {
    useMediaRecipe,
    useMediaFigureRecipe,
    useMediaBodyRecipe,
    useMediaTitleRecipe,
} from '@styleframe/theme';

const s = styleframe();

const media = useMediaRecipe(s);
const mediaFigure = useMediaFigureRecipe(s);
const mediaBody = useMediaBodyRecipe(s);
const mediaTitle = useMediaTitleRecipe(s);

export default s;

Build the component

Import the media, mediaFigure, mediaBody, and mediaTitle runtime functions from the virtual module and pass variant props to compute class names:

src/components/Media.tsx
import { media, mediaFigure, mediaBody, mediaTitle } from "virtual:styleframe";

interface MediaProps {
    orientation?: "horizontal" | "vertical";
    align?: "start" | "center" | "end";
    size?: "sm" | "md" | "lg";
    figure?: React.ReactNode;
    title?: string;
    children?: React.ReactNode;
}

export function Media({
    orientation = "horizontal",
    align = "start",
    size = "md",
    figure,
    title,
    children,
}: MediaProps) {
    return (
        <div className={media({ orientation, align, size })}>
            {figure && (
                <div className={mediaFigure({ size })}>{figure}</div>
            )}
            <div className={mediaBody({ size })}>
                {title && <h3 className={mediaTitle({ size })}>{title}</h3>}
                {children}
            </div>
        </div>
    );
}

See it in action

Variants

Two layout axes shape the media object: orientation and align. Together they cover the full set of media-object arrangements without forcing a color or surface choice.

Orientation

Two orientations control how the figure and body are arranged. horizontal places the figure to the left (the default media-object layout); vertical stacks the figure on top of the body, useful for media cards or compact list items where vertical space is plentiful.

Align

Three alignment options control how the figure aligns with the body on the cross-axis. start (the default) aligns the top of the figure with the top of the body, ideal for comments and posts where the avatar should sit next to the title. center vertically centers the figure for compact list items. end aligns the figure to the bottom of the body, useful when the visual is decorative.

AlignCSS ValueUse Case
startalign-items: flex-startDefault. Comments, posts, list rows where avatar aligns with title
centeralign-items: centerCompact list items, single-line content
endalign-items: flex-endDecorative figures, icon-as-suffix layouts

Sizes

Three size variants from sm to lg scale the gap between figure and body, the figure's border radius, the body's internal gap, and the title's font size in lockstep.

Size Reference

SizeContainer GapFigure RadiusBody GapTitle Font Size
sm@0.5@border-radius.sm@0.25@font-size.sm
md@0.75@border-radius.md@0.375@font-size.md
lg@1@border-radius.lg@0.5@font-size.lg
Good to know: The size prop must be passed to each sub-recipe individually. The container controls the figure-to-body gap, the figure controls its own border radius, the body controls the title-to-content gap, and the title controls its font size.

Anatomy

The Media recipe is composed of four independent recipes that work together to form the layout:

PartRecipeRole
ContaineruseMediaRecipe()Outer flex wrapper with orientation, align, and gap
FigureuseMediaFigureRecipe()Visual holder with flex-shrink: 0 and rounded crop
BodyuseMediaBodyRecipe()Text container with flex-grow: 1 and safe min-width: 0
TitleuseMediaTitleRecipe()Heading typography (element-agnostic)

Each part is a standalone recipe with its own size axis. The container's orientation and align props are independent of the size axis, so you can freely combine all three.

<!-- All four parts working together -->
<div class="media(...)">
    <div class="mediaFigure(...)"><!-- avatar / image --></div>
    <div class="mediaBody(...)">
        <h3 class="mediaTitle(...)">Heading</h3>
        <p>Body content</p>
    </div>
</div>
Pro tip: The title recipe is element-agnostic — apply the mediaTitle class to whichever heading element fits your document outline (<h2>, <h3>, <h4>, or even <strong>). The recipe only sets typography, not semantics.

Nesting

Media objects can be nested inside one another to model parent-child relationships such as comment threads, social-post replies, or activity feeds. Place a Media inside another Media's body and the layout naturally indents the child by the figure's width plus the container's gap.

src/components/CommentThread.vue
<template>
    <Media>
        <MediaFigure>
            <img src="/avatar/alex.png" alt="Alex Grozav" />
        </MediaFigure>
        <MediaBody>
            <MediaTitle>Alex Grozav</MediaTitle>
            <p>Just shipped the new Media recipe...</p>

            <!-- Nested reply -->
            <Media>
                <MediaFigure size="sm">
                    <img src="/avatar/jane.png" alt="Jane Smith" />
                </MediaFigure>
                <MediaBody size="sm">
                    <MediaTitle size="sm">Jane Smith</MediaTitle>
                    <p>Love it &mdash; the layout-only design...</p>
                </MediaBody>
            </Media>
        </MediaBody>
    </Media>
</template>
Good to know: Drop the nested Media directly inside the parent's MediaBody. The parent body is already a flex column with min-width: 0, so child media items wrap correctly without extra wrappers. Reduce the nested media's size (e.g., sm) to visually distinguish replies from the parent thread.

Accessibility

  • Choose the right heading level. useMediaTitleRecipe only styles typography — pick a heading element (<h2>, <h3>, <h4>) that fits the surrounding document outline. Don't skip levels just because the title is small.
  • Provide alt text for figures. When the figure contains an <img>, supply a meaningful alt attribute. Decorative figures should use alt="" and aria-hidden="true" so screen readers skip them.
  • Group related media in a list. Repeated media items (comments, feed posts) belong inside a <ul> / <ol> so assistive tech can announce the count.
  • Don't lose focus order with vertical orientation. Switching orientation with CSS doesn't reorder the DOM, so the figure is always announced before the body. Place actions and links inside the body for predictable keyboard navigation.

Customization

Overriding Defaults

Each media 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/media.styleframe.ts
import { styleframe } from 'virtual:styleframe';
import {
    useMediaRecipe,
    useMediaFigureRecipe,
    useMediaBodyRecipe,
    useMediaTitleRecipe,
} from '@styleframe/theme';

const s = styleframe();

const media = useMediaRecipe(s, {
    base: {
        gap: '@1',
    },
    defaultVariants: {
        orientation: 'horizontal',
        align: 'center',
        size: 'lg',
    },
});

const mediaFigure = useMediaFigureRecipe(s, {
    defaultVariants: {
        size: 'lg',
    },
});

const mediaBody = useMediaBodyRecipe(s, {
    defaultVariants: {
        size: 'lg',
    },
});

const mediaTitle = useMediaTitleRecipe(s, {
    base: {
        fontWeight: '@font-weight.bold',
    },
    defaultVariants: {
        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/media.styleframe.ts
import { styleframe } from 'virtual:styleframe';
import { useMediaRecipe } from '@styleframe/theme';

const s = styleframe();

// Only generate horizontal orientation with start and center alignment
const media = useMediaRecipe(s, {
    filter: {
        orientation: ['horizontal'],
        align: ['start', 'center'],
    },
});

export default s;
Good to know: Filtering also adjusts default variants that reference filtered-out values, so your recipe stays consistent.

API Reference

useMediaRecipe(s, options?)

Creates the media container recipe with orientation, alignment, and gap.

Parameters:

ParameterTypeDescription
sStyleframeThe Styleframe instance
optionsDeepPartial<RecipeConfig>Optional overrides for the recipe configuration
options.baseVariantDeclarationsBlockCustom base styles for the media container
options.variantsVariantsCustom variant definitions for the recipe
options.defaultVariantsRecord<keyof Variants, string>Default variant values for the recipe
options.filterRecord<string, string[]>Limit which variant values are generated

Variants:

VariantOptionsDefault
orientationhorizontal, verticalhorizontal
alignstart, center, endstart
sizesm, md, lgmd

useMediaFigureRecipe(s, options?)

Creates the media figure recipe for the visual holder. The base styles set flex-shrink: 0 so the figure never collapses, plus overflow: hidden so contained images crop to the rounded radius.

Variants:

VariantOptionsDefault
sizesm, md, lgmd

useMediaBodyRecipe(s, options?)

Creates the media body recipe for the text content area. The base styles set flex-grow: 1 and the critical min-width: 0 so long titles wrap correctly inside the parent flex container instead of overflowing.

Variants:

VariantOptionsDefault
sizesm, md, lgmd

useMediaTitleRecipe(s, options?)

Creates the media title recipe for heading typography. The base styles set semibold weight, snug line height, and zero margin. The recipe is element-agnostic — apply the class to any heading element (<h2>, <h3>, <h4>) that fits your document outline.

Variants:

VariantOptionsDefault
sizesm, md, lgmd

Learn more about recipes →

Best Practices

  • Pass size to every part: The container, figure, body, and title each control their own size-scaled property. Pass the same size value to all four for visually consistent results.
  • Use align: "start" for comments and posts: Top-aligning the avatar with the title is the most readable pattern when the body has multiple lines of text. Reserve center for single-line list items.
  • Pick a meaningful heading element: useMediaTitleRecipe only styles typography — choose <h3> or <h4> based on where the media sits in your document outline.
  • Use vertical orientation sparingly: Vertical media is essentially a card-like figure-on-top layout. If your design needs that pattern, also consider whether useCardRecipe is a better fit.
  • Don't add background or border to the Media root: Media is layout-only by design. Wrap it inside a Card, Callout, or other container if you need surface styling — this keeps composition predictable.
  • Filter what you don't need: If your component only uses one orientation or alignment, pass a filter option to reduce generated CSS.
  • Reduce size for nested replies: When nesting Media inside Media (comment threads, reply chains), drop the nested media's size to sm so the indentation hierarchy reads at a glance.

FAQ