Size context
Default and compact control sizes for a whole region, with a matching type scale.
- Status
- Experimental
- Level
- Utility
- Category
- Utilities
- Adoption
- Not used yet
import { SizeProvider } from "@oration/canon/lib/size-context";packages/canon/src/lib/size-context.tsx- 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
useTypeScalewhere 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
The Thirteen-Fourteen Rule
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.
| Role | Default | Compact |
|---|---|---|
| display | Northwind Freight 28px | Northwind Freight 24px |
| title | Northwind Freight 16px | Northwind Freight 15px |
| subtitle | Northwind Freight 14px | Northwind Freight 13px |
| body | Northwind Freight 13px | Northwind Freight 12px |
| caption | Northwind Freight 12px | Northwind 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.
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#
| State | Treatment |
|---|---|
| No provider | useSize() resolves to the default step. |
| Default | h-9 (36px) controls, text-[13px], px-3, gap-2, 16px icons. |
| Compact | h-7 (28px) controls, text-[12px], px-2.5, gap-1, 14px icons. |
| Controlled | With size, the provider ignores setSize so a region stays pinned. |
| Overridden | A 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". SizeProvideris uncontrolled withdefaultSize, or controlled withsize. A controlled provider ignoressetSizeso a background write can't surface later.useSizeContext()returns{ size, setSize, classes }and throws outside a provider;useSizeanduseSizeVariantnever throw.- Switch maps its legacy
size="sm"to compact, and reads the provider whensizeis omitted. Its label row uses the context'spx,gapandtext. classes.controlis 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#
size="sm", 28px toolbar controls, Switch size="compact".SizeProvider size="compact" and expect it to shrink. Only Switch responds; buttons and inputs stay 32px.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#
| Token | Used for |
|---|---|
h-9 / h-7 | control: 36 or 28px |
text-[13px] / text-[12px] | text: body inside controls |
px-3 / px-2.5 | px: bounded control padding |
px-2 / px-1.5 | itemPx: list and menu row padding |
gap-2 / gap-1 | gap: icon to label, control to control |
h-7 p-1 / h-6 p-0.5 | segmentItem and segmentPad |
API reference#
SizeProvider
From @oration/canon/lib/size-context.
Other props spread onto Nothing; only these props.
| Prop | Type | Default | Description |
|---|---|---|---|
childrenRequired | ReactNode | No default | The region. |
size | "default" | "compact" | No default | Controlled: pins the region. setSize is ignored. |
defaultSize | "default" | "compact" | "default" | Uncontrolled starting size. |
useSize
useSize(override?: SizeVariant | null): SizeClasses.
| Prop | Type | Default | Description |
|---|---|---|---|
override | "default" | "compact" | null | No default | A component's own size prop, which wins over the provider. |
Returns
SizeClasses, what useSize returns and sizeMap holds.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "compact" | No default | The resolved step. |
control | string | No default | "h-9" or "h-7". |
controlHeight | number | No default | 36 or 28. |
segmentItem | string | No default | "h-7" or "h-6". |
segmentPad | string | No default | "p-1" or "p-0.5". |
text | string | No default | "text-[13px]" or "text-[12px]". |
px | string | No default | "px-3" or "px-2.5". |
itemPx | string | No default | "px-2" or "px-1.5". |
gap | string | No default | "gap-2" or "gap-1". |
icon | number | No default | 16 or 14 (px). |
Other exports
| Prop | Type | Default | Description |
|---|---|---|---|
useSizeVariant | (override?: SizeVariant | null) => "default" | "compact" | No default | The resolved step only. |
useSizeContext | () => { size; setSize; classes } | No default | The provider's value. Throws outside a SizeProvider. |
useTypeScale | (override?: SizeVariant | null) => Record<TypeScaleRole, number> | No default | Pixel sizes for display, title, subtitle, body and caption. |
sizeMap | Record<SizeVariant, SizeClasses> | No default | Both steps, for reading outside React. |
typeScale | Record<TypeScaleRole, { default: number; compact: number }> | No default | display 28/24, title 16/15, subtitle 14/13, body 13/12, caption 12/11. |
Types | SizeVariant, SizeClasses, TypeScaleRole, TypeScaleStep | No default | Type 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.