Composables

Pagination

A navigation component for moving between pages of content. Supports horizontal and vertical orientations, three colors, six visual styles, three sizes, and active/disabled states through a three-part recipe system.

Overview

The Pagination is a navigation component used for moving between pages of a result set such as tables, search results, and content listings. It is composed of three recipe parts: usePaginationRecipe() for the container that controls layout direction and spacing, usePaginationItemRecipe() for individual page-number buttons with color, variant, and interactive state options, and usePaginationEllipsisRecipe() for the non-interactive element that collapses long page ranges. Each composable creates a fully configured recipe with compound variants that handle the color-variant combinations automatically.

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

The Pagination recipe helps you:

  • Ship faster with sensible defaults: Get 2 orientations, 3 colors, 6 visual styles, and 3 sizes out of the box with three composable calls.
  • Compose flexible navigation: Three coordinated recipes (container, item, ellipsis) share the size axis, so your pagination 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 Pagination 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/pagination.styleframe.ts
import { styleframe } from 'virtual:styleframe';
import {
    usePaginationRecipe,
    usePaginationItemRecipe,
    usePaginationEllipsisRecipe,
} from '@styleframe/theme';

const s = styleframe();

const pagination = usePaginationRecipe(s);
const paginationItem = usePaginationItemRecipe(s);
const paginationEllipsis = usePaginationEllipsisRecipe(s);

export default s;

Build the component

Import the pagination, paginationItem, and paginationEllipsis runtime functions from the virtual module and pass variant props to compute class names:

src/components/Pagination.tsx
import { pagination, paginationItem, paginationEllipsis } from "virtual:styleframe";

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

interface PaginationItemProps {
    color?: "light" | "dark" | "neutral";
    variant?: "solid" | "outline" | "soft" | "subtle" | "ghost" | "link";
    size?: "sm" | "md" | "lg";
    active?: boolean;
    disabled?: boolean;
    href?: string;
    "aria-label"?: string;
    children?: React.ReactNode;
}

export function Pagination({
    orientation = "horizontal",
    size = "md",
    children,
}: PaginationProps) {
    return (
        <nav
            className={pagination({ orientation, size })}
            role="navigation"
            aria-label="Pagination"
        >
            {children}
        </nav>
    );
}

export function PaginationItem({
    color = "neutral",
    variant = "ghost",
    size = "md",
    active = false,
    disabled = false,
    href,
    children,
    ...rest
}: PaginationItemProps) {
    const classes = paginationItem({
        color,
        variant,
        size,
        active: active ? "true" : "false",
        disabled: disabled ? "true" : "false",
    });
    const Tag = href ? "a" : "button";
    return (
        <Tag
            className={classes}
            href={href && !disabled ? href : undefined}
            disabled={!href && disabled ? true : undefined}
            aria-current={active ? "page" : undefined}
            aria-disabled={disabled || undefined}
            {...rest}
        >
            {children}
        </Tag>
    );
}

export function PaginationEllipsis({ size = "md" }: { size?: "sm" | "md" | "lg" }) {
    return (
        <span className={paginationEllipsis({ size })} aria-hidden="true">
        </span>
    );
}

See it in action

Orientation

The Pagination container recipe supports two orientations that control the flex layout direction. Horizontal arranges items in a row (the default and most common pagination layout), while vertical stacks items in a column for sidebar-style page lists.

Colors

The Pagination Item recipe includes 3 color variants: light, dark, and neutral. These match the Container color pattern used by Card, Modal, and Tooltip — surface-spectrum colors designed for navigation chrome 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 hover, focus, active, and dark mode states.

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 pagination.

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 pagination color. It adapts automatically to the user's color scheme, so you don't need to manage light and dark variants separately.

Variants

Six visual style variants control how the page-number items are rendered. Each variant is combined with the selected color through compound variants, so you always get the correct background, text, and border colors for your chosen color.

Solid

Filled background with contrasting text. The most prominent style, ideal when pagination is the primary navigation control on the page.

Outline

Transparent background with colored border and text. Useful when the pagination should remain visible without dominating the layout.

Soft

Light tinted background with no visible border. A subtle but visible style that works well for grouped pagination in dense table headers.

Subtle

Light tinted background with a matching border. Combines the softness of soft with added visual definition from a border.

Ghost

Transparent background that reveals a tinted background on hover. The default variant — ideal when pagination should appear as plain text until interaction.

Styled as inline text links with no background or border. On hover, the text darkens and gains an underline. Use when pagination should look like ordinary navigation links.

Sizes

Three size variants from sm to lg control the font size, padding, and gap of the items, the ellipsis, and the container. Pass the same size value to all three sub-recipes so heights stay aligned.

SizeItem Font SizeItem Padding (V / H)Container Gap
sm@font-size.sm@0.375 / @0.625@0.25
md@font-size.sm@0.5 / @0.75@0.375
lg@font-size.md@0.625 / @0.875@0.5
Good to know: The size prop must be passed to each sub-recipe individually. The container controls the gap and overall scale, while each item and ellipsis manages its own padding and font size.

Anatomy

The Pagination recipe is composed of three independent recipes that work together to form a navigation row:

PartRecipeRole
ContainerusePaginationRecipe()Outer <nav> flex container that controls orientation and gap
ItemusePaginationItemRecipe()Individual page-number button (also used for prev/next/first/last with icon content)
EllipsisusePaginationEllipsisRecipe()Non-interactive <span> that renders between page ranges

Each part is a standalone recipe with its own set of variants. The color and variant props belong to the item recipe, since the container is purely structural and the ellipsis is always neutral. Pass the size prop consistently to the container and every item / ellipsis so heights line up.

<!-- All three parts working together -->
<nav class="pagination(...)" role="navigation" aria-label="Pagination">
    <button class="paginationItem(...)" aria-label="Previous page"></button>
    <a class="paginationItem(...)" href="?page=1">1</a>
    <a class="paginationItem(...) paginationItem--active-true" href="?page=2" aria-current="page">2</a>
    <a class="paginationItem(...)" href="?page=3">3</a>
    <span class="paginationEllipsis(...)" aria-hidden="true"></span>
    <a class="paginationItem(...)" href="?page=9">9</a>
    <button class="paginationItem(...)" aria-label="Next page"></button>
</nav>
Pro tip: Prev / Next / First / Last controls are not separate recipes — render them as <PaginationItem> instances with an icon child (, , «, ») and an aria-label. The item recipe handles their styling automatically, including the disabled state for "previous on page 1" and "next on the last page".

Accessibility

  • Use a <nav> landmark. The container should render as <nav> with role="navigation" (implicit on <nav>) and an aria-label="Pagination" so assistive technologies announce it as a distinct navigation region.
  • Mark the current page. Add aria-current="page" to the active item. Screen readers announce "current page" alongside the page number.
  • Label the prev/next/first/last controls. These items typically render with icon content (, , «, »). Add an explicit aria-label="Previous page", "Next page", "First page", or "Last page" so the control has an accessible name.
  • Hide the ellipsis from assistive tech. The ellipsis is purely visual. Set aria-hidden="true" so screen readers skip it instead of announcing "horizontal ellipsis".
  • Disable, don't hide. When the user is on the first page, mark the previous control with disabled (button) or aria-disabled="true" (anchor) instead of removing it. Layout stability prevents the page numbers from shifting.
  • Keyboard navigation. Items render as <button> or <a>, which are natively focusable and activatable with Enter / Space. The :focus-visible ring in the recipe base styles ensures the current focus is always visible.

Customization

Overriding Defaults

Each pagination 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/pagination.styleframe.ts
import { styleframe } from 'virtual:styleframe';
import {
    usePaginationRecipe,
    usePaginationItemRecipe,
    usePaginationEllipsisRecipe,
} from '@styleframe/theme';

const s = styleframe();

const pagination = usePaginationRecipe(s, {
    defaultVariants: {
        orientation: 'horizontal',
        size: 'lg',
    },
});

const paginationItem = usePaginationItemRecipe(s, {
    base: { borderRadius: '@border-radius.full' },
    defaultVariants: {
        color: 'neutral',
        variant: 'soft',
        size: 'lg',
        active: 'false',
        disabled: 'false',
    },
});

const paginationEllipsis = usePaginationEllipsisRecipe(s, {
    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/pagination.styleframe.ts
import { styleframe } from 'virtual:styleframe';
import { usePaginationItemRecipe } from '@styleframe/theme';

const s = styleframe();

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

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

usePaginationRecipe(s, options?)

Creates the pagination container recipe with <nav>-friendly base styles, an orientation axis, and a size axis.

Parameters:

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

usePaginationItemRecipe(s, options?)

Creates the pagination item recipe for individual page-number buttons. Renders consistently as a <button> or <a> and supports the full Interactive variant set plus boolean active and disabled axes.

Parameters: identical to usePaginationRecipe.

Variants:

VariantOptionsDefault
colorlight, dark, neutralneutral
variantsolid, outline, soft, subtle, ghost, linkghost
sizesm, md, lgmd
activetrue, falsefalse
disabledtrue, falsefalse

usePaginationEllipsisRecipe(s, options?)

Creates the pagination ellipsis recipe for the non-interactive element. Always renders with the @color.text-weak token; only the size axis is exposed.

Parameters: identical to usePaginationRecipe.

Variants:

VariantOptionsDefault
sizesm, md, lgmd

Learn more about recipes →

Best Practices

  • Always wrap pagination in a <nav>: The container renders as a navigation landmark with aria-label="Pagination" so assistive tech can jump straight to it.
  • Mark the current page with aria-current="page": Set it on the item with active=true so screen readers announce the current position in the page list.
  • Pass size to every sub-recipe: The container, items, and ellipsis each manage their own dimensions. Skipping one breaks vertical alignment.
  • Style the active page distinctly: The default active axis only bumps font-weight to semibold. For a stronger highlight, pass variant="solid" to the active item while siblings stay on ghost — see the active-vs-inactive contrast example.
  • Disable, don't hide, the edge controls: When the user is on the first or last page, set disabled on the corresponding prev/next/first/last item rather than removing it. Layout stability beats minor space savings.
  • Filter what you don't need: Pass a filter option (e.g., color: ['neutral']) to reduce generated CSS in projects that only use one color.
  • Override defaults at the recipe level: If your pagination is always large and outlined, set those as defaultVariants so component consumers write less code.

FAQ