Skip to content

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

Tight6pxrounded-sm
Small8pxrounded-md
Standard10pxrounded-lg
Container12pxrounded-xl
RoundFullrounded-full
import { 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>    );}
Radius tokens
ClassTokenSizeRoleUsed for
rounded-smcalc(var(--radius) * 0.6)6pxTightKeyboard keys, inline code, breadcrumb links, focus outlines on bare links, tiny row actions.
rounded-mdcalc(var(--radius) * 0.8)8pxSmallTags, menu and select items, tab triggers, view tabs, tooltips, sidebar items, small and extra-small buttons.
rounded-lgvar(--radius)10pxStandardButtons, inputs, selects, textareas, popovers and menus, the default tab track, tint wells, icon tiles, the sidebar search field.
rounded-xlcalc(var(--radius) * 1.2)12pxContainerCards, panels, dialogs, kanban columns, flow nodes, empty states and the floating action bar.
rounded-2xlcalc(var(--radius) * 1.6)16pxNot usedNot used in Canon. Nothing in the suite rounds more than a card.
rounded-3xlcalc(var(--radius) * 2.2)22pxNot usedNot used in Canon. Nothing in the suite rounds more than a card.
rounded-4xlcalc(var(--radius) * 2.6)26pxNot usedNot used in Canon. Nothing in the suite rounds more than a card.
rounded-full9999pxFullRoundStatus 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.

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.

Concentric12px outer, 4px padding, 8px inner
Same radius inside12px outer, 4px padding, 12px inner

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>    );}
OpenPaidVoid
Do. Inside a 10px track with 2px padding, give the active item 8px corners. Segmented controls and tab tracks are built this way.
OpenPaidVoid
Don't. Repeat the container's 10px radius inside it. The inner corner bulges toward the outer one and the gap pinches.
  • 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.

TriggerInvoice received
ActionMatch to purchase order
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>    );}
Grid and edges in Flow
// 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

Active
Do. Use the container radius for the card, the small radius for the tag and the standard radius for the button.

Halcyon

Active
Don't. Round a card to 22px, square a tag and pill a button. Each corner then speaks a different language.