Skip to content

Size context

Default and compact control sizes for a whole region, with a matching type scale.

Level
Utility
Category
Utilities
Adoption
Not used yet
import { SizeProvider } from "@oration/canon/lib/size-context";
packages/canon/src/lib/size-context.tsx
Auto-approve invoices under $500
Notify Priya Raman on exceptions
control
h-9, 36px
text
text-[13px]
px
px-3
gap
gap-2
icon
16px
import { SegmentedControl } from "@oration/canon/components/segmented-control";import { Switch } from "@oration/canon/components/switch";import { SizeProvider, type SizeVariant, sizeMap } from "@oration/canon/lib/size-context";import * as React from "react";export function Hero() {    const [size, setSize] = React.useState<SizeVariant>("default");    const [autoApprove, setAutoApprove] = React.useState(true);    const [notify, setNotify] = React.useState(false);    const classes = sizeMap[size];    return (        <div className="flex w-full max-w-md flex-col gap-4">            <SegmentedControl<SizeVariant>                label="Region size"                value={size}                onValueChange={setSize}                options={[                    { value: "default", label: "Default" },                    { value: "compact", label: "Compact" },                ]}            />            <SizeProvider size={size}>                <div className="flex flex-col rounded-xl bg-card py-1 shadow-border">                    <Switch                        label="Auto-approve invoices under $500"                        checked={autoApprove}                        onCheckedChange={setAutoApprove}                    />                    <Switch                        label="Notify Priya Raman on exceptions"                        checked={notify}                        onCheckedChange={setNotify}                    />                </div>            </SizeProvider>            <dl className="grid grid-cols-[7rem_1fr] gap-x-3 gap-y-1 rounded-[10px] bg-muted/70 px-3 py-2.5">                {(                    [                        [                            "control",                            `${classes.control}, ${classes.controlHeight}px`,                        ],                        ["text", classes.text],                        ["px", classes.px],                        ["gap", classes.gap],                        ["icon", `${classes.icon}px`],                    ] as const                ).map(([key, value]) => (                    <div key={key} className="contents">                        <dt className="font-mono text-xs leading-5 text-muted-foreground">                            {key}                        </dt>                        <dd className="font-mono text-xs leading-5 text-foreground">                            {value}                        </dd>                    </div>                ))}            </dl>        </div>    );}

Usage#

Size context lets a region choose one of two control densities, default or compact, and hands components the matching height, padding, gap, icon and text classes, plus a role-based type scale. SizeProvider sets it and useSize reads it. Today only Switch reads it, and nothing in apps/web renders a SizeProvider, so it is experimental. Its default step is a 36px control with 13px text, which disagrees with DESIGN.md's 32px control height.

When to use

  • Experimentally, to step a region's switches down to compact, such as a dense filter popover.
  • Inside a new packages/canon control that should follow a region's density, through useSize(props.size).
  • To read type sizes as numbers with useTypeScale where classes can't reach, such as chart or canvas labels.

When not to use

  • Making buttons smaller. Button doesn't read the context; pass size="sm". Use Button
  • A dense toolbar. Toolbars use 28px controls directly. Use Toolbar
  • Table and grid density. Rows have their own 32, 36 and 44px densities. Use Data grid
  • Picking a font size for a block of text. Use the type ramp classes. Use Typography

Control heights

Controls are 32px (28px in toolbars, 36px large). Size context's default is 36px and its compact is 28px, so its default step is the suite's large.

The Thirteen-Fourteen Rule

Dense UI is 13px, reading text is 14px, meta is 12px. 11px is for footnotes only, and nothing is set smaller. The compact column steps body to 12px and caption to 11px, below this ramp.

Examples#

Type scale

typeScale holds each role in pixels at both steps; useTypeScale() resolves them for the current region. The compact column steps every role down one notch.

RoleDefaultCompact
displayNorthwind Freight 28pxNorthwind Freight 24px
titleNorthwind Freight 16pxNorthwind Freight 15px
subtitleNorthwind Freight 14pxNorthwind Freight 13px
bodyNorthwind Freight 13pxNorthwind Freight 12px
captionNorthwind Freight 12pxNorthwind Freight 11px
import { typeScale } from "@oration/canon/lib/size-context";export function TypeScale() {    const roles = ["display", "title", "subtitle", "body", "caption"] as const;    return (        <table className="w-full max-w-lg border-collapse text-left">            <thead>                <tr className="text-xs text-muted-foreground">                    <th scope="col" className="pb-2 font-medium">                        Role                    </th>                    <th scope="col" className="pb-2 font-medium">                        Default                    </th>                    <th scope="col" className="pb-2 font-medium">                        Compact                    </th>                </tr>            </thead>            <tbody>                {roles.map((role) => (                    <tr key={role} className="border-t border-border">                        <th                            scope="row"                            className="py-2 pr-4 font-mono text-xs font-normal text-muted-foreground"                        >                            {role}                        </th>                        {(["default", "compact"] as const).map((step) => (                            <td                                key={step}                                className="py-2 pr-4 text-foreground"                            >                                <span                                    className="tabular-nums"                                    style={{ fontSize: typeScale[role][step] }}                                >                                    Northwind Freight {typeScale[role][step]}px                                </span>                            </td>                        ))}                    </tr>                ))}            </tbody>        </table>    );}

Default height against the suite

The context's default control is 36px, one step taller than the 32px Input and Button beside it. Its compact step matches Button sm at 28px.

Size context default, 36pxSearch suppliers
Input and Button default, 32px
Size context compact and Button sm, 28px
Search suppliers
import { Button } from "@oration/canon/components/button";import { Input } from "@oration/canon/components/input";import { sizeMap } from "@oration/canon/lib/size-context";import { cn } from "@oration/canon/lib/utils";export function HeightGap() {    const rows = [        {            label: "Size context default, 36px",            node: (                <span                    className={cn(                        "flex w-40 items-center rounded-lg border border-input",                        sizeMap.default.control,                        sizeMap.default.px,                        sizeMap.default.text,                    )}                >                    Search suppliers                </span>            ),        },        {            label: "Input and Button default, 32px",            node: (                <div className="flex items-center gap-2">                    <Input                        aria-label="Search suppliers"                        placeholder="Search suppliers"                        tabIndex={-1}                        className="pointer-events-none w-40"                    />                    <Button                        type="button"                        variant="outline"                        tabIndex={-1}                        className="pointer-events-none"                    >                        Filter                    </Button>                </div>            ),        },        {            label: "Size context compact and Button sm, 28px",            node: (                <div className="flex items-center gap-2">                    <span                        className={cn(                            "flex w-40 items-center rounded-lg border border-input",                            sizeMap.compact.control,                            sizeMap.compact.px,                            sizeMap.compact.text,                        )}                    >                        Search suppliers                    </span>                    <Button                        type="button"                        variant="outline"                        size="sm"                        tabIndex={-1}                        className="pointer-events-none"                    >                        Filter                    </Button>                </div>            ),        },    ];    return (        <div className="flex w-full max-w-md flex-col gap-4">            {rows.map((row) => (                <div key={row.label} className="flex flex-col gap-1.5">                    <span className="text-xs text-muted-foreground">                        {row.label}                    </span>                    {row.node}                </div>            ))}        </div>    );}

States#

States
StateTreatment
No provideruseSize() resolves to the default step.
Defaulth-9 (36px) controls, text-[13px], px-3, gap-2, 16px icons.
Compacth-7 (28px) controls, text-[12px], px-2.5, gap-1, 14px icons.
ControlledWith size, the provider ignores setSize so a region stays pinned.
OverriddenA component's own size prop beats the provider: explicit prop, then provider, then default.

Behavior#

  • Resolution order everywhere: the component's explicit size, then the nearest SizeProvider, then "default".
  • SizeProvider is uncontrolled with defaultSize, or controlled with size. A controlled provider ignores setSize so a background write can't surface later.
  • useSizeContext() returns { size, setSize, classes } and throws outside a provider; useSize and useSizeVariant never throw.
  • Switch maps its legacy size="sm" to compact, and reads the provider when size is omitted. Its label row uses the context's px, gap and text.
  • classes.control is one height for bounded controls and for list and menu rows, so a popup row lines up with the trigger that opened it.

Do and don't#

Active only
Do. Size dense regions explicitly today: Button size="sm", 28px toolbar controls, Switch size="compact".
Active only
Don't. Wrap a region in SizeProvider size="compact" and expect it to shrink. Only Switch responds; buttons and inputs stay 32px.
Do. Take text sizes from the type ramp: 13px dense, 14px reading, 12px meta.
Don't. Use the compact caption (11px) for descriptions or errors. 11px is for footnotes only.

Accessibility#

  • Compact controls are 28px: fine for pointer surfaces, too small as the only size on touch, where controls stay at 32px or more.
  • The compact switch track is 28 by 16px. Keep its label, which widens the hit area to the whole row.
  • Compact text steps to 12px body and 11px captions; check contrast and legibility before using it for anything people read closely.

Design tokens#

Design tokens
TokenUsed for
h-9 / h-7control: 36 or 28px
text-[13px] / text-[12px]text: body inside controls
px-3 / px-2.5px: bounded control padding
px-2 / px-1.5itemPx: list and menu row padding
gap-2 / gap-1gap: icon to label, control to control
h-7 p-1 / h-6 p-0.5segmentItem and segmentPad

API reference#

SizeProvider

From @oration/canon/lib/size-context.

Other props spread onto Nothing; only these props.

Props of SizeProvider
PropTypeDefaultDescription
childrenRequiredReactNodeNo defaultThe region.
size"default" | "compact"No defaultControlled: pins the region. setSize is ignored.
defaultSize"default" | "compact""default"Uncontrolled starting size.

useSize

useSize(override?: SizeVariant | null): SizeClasses.

Props of useSize
PropTypeDefaultDescription
override"default" | "compact" | nullNo defaultA component's own size prop, which wins over the provider.

Returns

SizeClasses, what useSize returns and sizeMap holds.

Props of Returns
PropTypeDefaultDescription
variant"default" | "compact"No defaultThe resolved step.
controlstringNo default"h-9" or "h-7".
controlHeightnumberNo default36 or 28.
segmentItemstringNo default"h-7" or "h-6".
segmentPadstringNo default"p-1" or "p-0.5".
textstringNo default"text-[13px]" or "text-[12px]".
pxstringNo default"px-3" or "px-2.5".
itemPxstringNo default"px-2" or "px-1.5".
gapstringNo default"gap-2" or "gap-1".
iconnumberNo default16 or 14 (px).

Other exports

Props of Other exports
PropTypeDefaultDescription
useSizeVariant(override?: SizeVariant | null) => "default" | "compact"No defaultThe resolved step only.
useSizeContext() => { size; setSize; classes }No defaultThe provider's value. Throws outside a SizeProvider.
useTypeScale(override?: SizeVariant | null) => Record<TypeScaleRole, number>No defaultPixel sizes for display, title, subtitle, body and caption.
sizeMapRecord<SizeVariant, SizeClasses>No defaultBoth steps, for reading outside React.
typeScaleRecord<TypeScaleRole, { default: number; compact: number }>No defaultdisplay 28/24, title 16/15, subtitle 14/13, body 13/12, caption 12/11.
TypesSizeVariant, SizeClasses, TypeScaleRole, TypeScaleStepNo defaultType exports.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

The default control is 36px (h-9), but DESIGN.md sets controls at 32px, with 28px for toolbars and 36px for large. Default here is the suite's large.

Nothing in apps/web renders a SizeProvider, and only Switch reads useSize, so the context has no effect anywhere in the product today.

The compact step sets body at 12px and captions at 11px, below The Thirteen-Fourteen Rule.

typeScale.display is 28px, but DESIGN.md's Display role is 24px and reserved for the Home greeting.

Text classes are arbitrary values (text-[13px], text-[12px]) rather than the text-13 and text-xs ramp steps.