Shape
Four radii, 6, 8, 10 and 12px, stepped by size and nested concentrically.
Four radii and round#
Gently rounded rectangles on a 10px base: 6, 8, 10 and 12px, stepped by size and nesting depth, plus full rounding for things that are round.
The radius scale
rounded-smrounded-mdrounded-lgrounded-xlrounded-fullimport { cn } from "@oration/canon/lib/utils";export function RadiusScale() { const steps = [ { name: "Tight", px: 6, className: "rounded-sm" }, { name: "Small", px: 8, className: "rounded-md" }, { name: "Standard", px: 10, className: "rounded-lg" }, { name: "Container", px: 12, className: "rounded-xl" }, { name: "Round", px: 9999, className: "rounded-full" }, ]; return ( <div className="flex flex-wrap items-end justify-center gap-x-8 gap-y-6"> {steps.map((step) => ( <div key={step.name} className="flex flex-col items-center gap-3" > <span aria-hidden="true" className={cn( "size-16 border-t-2 border-l-2 border-foreground/70 bg-muted", step.className, )} /> <span className="flex flex-col items-center"> <span className="text-13 font-medium text-foreground"> {step.name} </span> <span className="text-xs text-muted-foreground tabular-nums"> {step.px === 9999 ? "Full" : `${step.px}px`} </span> <code className="font-mono text-xs text-muted-foreground"> {step.className} </code> </span> </div> ))} </div> );}| Class | Token | Size | Role | Used for |
|---|---|---|---|---|
rounded-sm | calc(var(--radius) * 0.6) | 6px | Tight | Keyboard keys, inline code, breadcrumb links, focus outlines on bare links, tiny row actions. |
rounded-md | calc(var(--radius) * 0.8) | 8px | Small | Tags, menu and select items, tab triggers, view tabs, tooltips, sidebar items, small and extra-small buttons. |
rounded-lg | var(--radius) | 10px | Standard | Buttons, inputs, selects, textareas, popovers and menus, the default tab track, tint wells, icon tiles, the sidebar search field. |
rounded-xl | calc(var(--radius) * 1.2) | 12px | Container | Cards, panels, dialogs, kanban columns, flow nodes, empty states and the floating action bar. |
rounded-2xl | calc(var(--radius) * 1.6) | 16px | Not used | Not used in Canon. Nothing in the suite rounds more than a card. |
rounded-3xl | calc(var(--radius) * 2.2) | 22px | Not used | Not used in Canon. Nothing in the suite rounds more than a card. |
rounded-4xl | calc(var(--radius) * 2.6) | 26px | Not used | Not used in Canon. Nothing in the suite rounds more than a card. |
rounded-full | 9999px | Full | Round | Status dots, avatars, meters and progress bars, switches, the edge insert button. |
Every step is computed from one variable, --radius, set to 0.625rem (10px) in packages/canon/src/styles/globals.css, and read from that file when this page builds. DESIGN.md names the same steps: sm 6px, md 8px, lg 10px, xl 12px, full 9999px. Write the class, not the pixels, so a change to --radius reaches every corner.
Tint wells are written rounded-[10px] across the product, in Well and in these docs, so they won't follow a change to --radius. Product list rows inside cards often use 10px where DESIGN.md says 8px; these pages follow DESIGN.md.
What uses each#
Small things get small corners. The radius grows with the size of the surface and shrinks as it nests.
Radii in real components
Keys and inline code at 6px, tags and list items at 8px, fields and buttons at 10px, cards at 12px, and dots, avatars and meters fully round.
6px and 8px
net_30ApprovedIn review10px
12px
This card, dialogs, panels, kanban columns, flow nodes, empty states and the floating action bar.
Full
import { Avatar, AvatarFallback } from "@oration/canon/components/avatar";import { Button } from "@oration/canon/components/button";import { Input } from "@oration/canon/components/input";import { Kbd } from "@oration/canon/components/kbd";import { StatusLabel } from "@oration/canon/components/status-dot";import { Tag } from "@oration/canon/components/tag";import { toast } from "@oration/canon/components/toast";import { SendIcon } from "lucide-react";export function RadiusInUse() { return ( <div className="grid w-full max-w-2xl gap-4 sm:grid-cols-2"> <div className="flex min-w-0 flex-col gap-3 rounded-xl bg-card p-4 shadow-border"> <p className="text-sm font-semibold text-foreground"> 6px and 8px </p> <div className="flex flex-wrap items-center gap-2"> <Kbd>⌘K</Kbd> <code className="rounded-sm bg-muted px-1 py-px font-mono text-xs text-foreground"> net_30 </code> <Tag color="green">Approved</Tag> <Tag color="amber">In review</Tag> </div> <div className="flex flex-col rounded-[10px] bg-muted/70 p-1"> <span className="flex h-8 items-center rounded-md bg-background px-2 text-13 text-foreground shadow-border"> Northwind Freight </span> <span className="flex h-8 items-center rounded-md px-2 text-13 text-muted-foreground"> Halcyon </span> </div> </div> <div className="flex min-w-0 flex-col gap-3 rounded-xl bg-card p-4 shadow-border"> <p className="text-sm font-semibold text-foreground">10px</p> <Input aria-label="Remittance email" defaultValue="ap@halcyon.example" /> <div className="flex flex-wrap gap-2"> <Button type="button" variant="outline"> Save draft </Button> <Button type="button" onClick={() => toast.add({ type: "success", title: "Remittance sent", description: "Halcyon, $8,212.75.", }) } > <SendIcon data-icon="inline-start" aria-hidden="true" /> Send remittance </Button> </div> </div> <div className="flex min-w-0 flex-col gap-3 rounded-xl bg-card p-4 shadow-border"> <p className="text-sm font-semibold text-foreground">12px</p> <p className="text-13 text-pretty text-muted-foreground"> This card, dialogs, panels, kanban columns, flow nodes, empty states and the floating action bar. </p> </div> <div className="flex min-w-0 flex-col gap-3 rounded-xl bg-card p-4 shadow-border"> <p className="text-sm font-semibold text-foreground">Full</p> <div className="flex flex-wrap items-center gap-3"> <Avatar size="sm"> <AvatarFallback>PR</AvatarFallback> </Avatar> <StatusLabel tone="success">Passed</StatusLabel> <span aria-hidden="true" className="h-1.5 w-24 overflow-hidden rounded-full bg-muted" > <span className="block h-full w-[62%] rounded-full bg-ink-65" /> </span> </div> </div> </div> );}Concentric radii#
An inner element's radius is its container's radius minus the padding between them, so the two curves share a center.
Inner equals outer minus padding
Change the padding and watch the concentric corner keep an even gap. The same radius inside crowds the corner and leaves a thick wedge between the curves.
Drawn at four times size.
import { SegmentedControl } from "@oration/canon/components/segmented-control";import * as React from "react";export function Concentric() { const [padding, setPadding] = React.useState<"2" | "3" | "4" | "6">("4"); const [outer, setOuter] = React.useState<"10" | "12">("12"); const p = Number(padding); const r = Number(outer); const inner = Math.max(0, r - p); const zoom = 4; const box = (radius: number) => ({ borderRadius: radius * zoom, padding: p * zoom, }); return ( <div className="flex w-full flex-col items-center gap-5"> <div className="flex flex-wrap items-center justify-center gap-2"> <SegmentedControl label="Outer radius" value={outer} onValueChange={setOuter} options={[ { value: "10", label: "Outer 10px" }, { value: "12", label: "Outer 12px" }, ]} /> <SegmentedControl label="Padding" value={padding} onValueChange={setPadding} options={[ { value: "2", label: "2px" }, { value: "3", label: "3px" }, { value: "4", label: "4px" }, { value: "6", label: "6px" }, ]} /> </div> <div className="grid w-full max-w-xl gap-6 sm:grid-cols-2"> {[ { label: "Concentric", radius: inner, good: true }, { label: "Same radius inside", radius: r, good: false }, ].map((item) => ( <figure key={item.label} className="flex flex-col items-center gap-3" > <div aria-hidden="true" className="w-full max-w-[15rem] overflow-hidden bg-card shadow-border" style={box(r)} > <div className="h-20 bg-ink-15" style={{ borderRadius: item.radius * zoom }} /> </div> <figcaption className="flex flex-col items-center text-center"> <span className="text-13 font-medium text-foreground"> {item.label} </span> <span className="text-xs text-muted-foreground tabular-nums"> {r}px outer, {p}px padding, {item.radius}px inner </span> </figcaption> </figure> ))} </div> <p className="text-xs text-muted-foreground"> Drawn at four times size. </p> </div> );}- This is why buttons inside button groups and toolbars step down to 8px, and why the tab track's 3px inset holds 8px triggers.
- The rule matters most when the padding is small. Deeper inside a card, an element takes the radius its own size calls for: a 10px well or button inside a 12px card with 16px padding.
Button group disagrees with DESIGN.md: in code, small and extra-small buttons in a group step up to 10px and inner corners go square, where the system asks for 8px.
Checkboxes and radios#
Checkboxes are 16px squares with a 4px corner, the one radius outside the scale. Radios and switch tracks are round.
Square for many, round for one
The corner tells the two apart before the label does: a square picks any number, a circle picks one of a set.
Checkbox, 4px corner
Radio, round
import { Checkbox } from "@oration/canon/components/checkbox";import { RadioGroup, RadioGroupItem } from "@oration/canon/components/radio-group";import * as React from "react";export function SelectionControls() { const [checked, setChecked] = React.useState(true); return ( <div className="flex flex-wrap items-start justify-center gap-x-12 gap-y-6"> <div className="flex flex-col gap-2.5"> <p className="text-xs text-muted-foreground"> Checkbox, 4px corner </p> <label className="flex items-center gap-2 text-13 text-foreground"> <Checkbox checked={checked} onCheckedChange={(value) => setChecked(value)} /> Email remittance advice </label> <label className="flex items-center gap-2 text-13 text-foreground"> <Checkbox defaultChecked={false} /> Require a second approver </label> </div> <div className="flex flex-col gap-2.5"> <p className="text-xs text-muted-foreground">Radio, round</p> <RadioGroup defaultValue="ach" aria-label="Payment method"> <label className="flex items-center gap-2 text-13 text-foreground"> <RadioGroupItem value="ach" /> ACH </label> <label className="flex items-center gap-2 text-13 text-foreground"> <RadioGroupItem value="wire" /> Wire </label> </RadioGroup> </div> </div> );}The checkbox's 4px is written rounded-[4px] in Checkbox.
The canvas grid#
Node canvases carry a dotted grid: 1.2px dots every 18px in Firm Hairline on a transparent ground. No other surface uses a pattern.
Nodes on the dotted grid
Nodes are 12px cards with hairline lift. Edges are 1.5px Firm Hairline smooth-steps bent at a 10px radius, with a 20px round insert button at the midpoint. During a test run the active edge becomes a 1.75px dashed indigo line that marches.
import { SegmentedControl } from "@oration/canon/components/segmented-control";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import { PlusIcon, ZapIcon } from "lucide-react";import * as React from "react";export function CanvasGrid() { const [running, setRunning] = React.useState(false); return ( <div className="flex w-full flex-col items-center gap-4"> <SegmentedControl label="Canvas state" value={running ? "running" : "rest"} onValueChange={(value) => setRunning(value === "running")} options={[ { value: "rest", label: "At rest" }, { value: "running", label: "Test run" }, ]} /> <div className="w-full max-w-[34rem] overflow-x-auto rounded-xl shadow-border"> <div className="relative h-64 w-[34rem]" style={{ backgroundImage: "radial-gradient(circle at 0.6px 0.6px, var(--border-strong) 0.6px, transparent 0.6px)", backgroundSize: "18px 18px", }} > <svg aria-hidden="true" className="absolute inset-0 size-full overflow-visible" viewBox="0 0 544 256" > <path d="M128 84 V118 Q128 128 138 128 H382 Q392 128 392 138 V172" fill="none" stroke={ running ? "var(--primary)" : "var(--border-strong)" } strokeWidth={running ? 1.75 : 1.5} strokeDasharray={running ? "5 5" : undefined} className={ running ? "animate-dash motion-reduce:animate-none" : undefined } /> </svg> {[ { left: 24, top: 28, title: "Invoice received", kind: "Trigger", }, { left: 288, top: 172, title: "Match to purchase order", kind: "Action", }, ].map((node) => ( <div key={node.title} className={cn( "absolute flex h-14 w-52 items-center gap-2.5 rounded-xl bg-card px-3 shadow-border", running && node.kind === "Action" && "ring-2 ring-primary/60", )} style={{ left: node.left, top: node.top }} > <span className="flex size-8 shrink-0 items-center justify-center rounded-[10px] bg-muted"> <ZapIcon aria-hidden="true" className="size-4 text-muted-foreground" /> </span> <span className="flex min-w-0 flex-col"> <span className="text-xs text-muted-foreground"> {node.kind} {running && node.kind === "Action" ? ", running" : ""} </span> <span className="truncate text-13 font-medium text-foreground"> {node.title} </span> </span> </div> ))} <span aria-hidden="true" className="absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-[1.5px] border-border-strong bg-background" style={{ left: 128, top: 84 }} /> <span aria-hidden="true" className="absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-[1.5px] border-border-strong bg-background" style={{ left: 392, top: 172 }} /> <button type="button" aria-label="Add step between Invoice received and Match to purchase order" onClick={() => toast.add({ title: "Add a step", description: "Between Invoice received and Match to purchase order.", }) } className="absolute flex size-5 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-background text-muted-foreground shadow-border transition-colors duration-150 hover:text-foreground" style={{ left: 260, top: 128 }} > <PlusIcon aria-hidden="true" className="size-3" /> </button> </div> </div> </div> );}// packages/canon/src/components/flow.tsx<Background variant={BackgroundVariant.Dots} gap={18} size={1.2} color="var(--border-strong)"/>// Edges bend at a 10px radiusconst [path, labelX, labelY] = getSmoothStepPath({ sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, borderRadius: 10,});- The grid belongs to node canvases built on Flow and nowhere else.
- Don't put dots, lines or noise behind pages, cards, empty states or marketing panels.
- The grid sits on a transparent ground, so the plane color shows through in both themes.
- Edge bends use 10px, the standard radius, the same as buttons and wells.
Do and don't#
Most shape mistakes come from reaching outside the four radii.
Halcyon
ActiveHalcyon
Active