Responsive
Breakpoints at 640, 768 and 1024px and how each surface folds down to a phone.
Breakpoints#
Three breakpoints shape the product: 640, 768 and 1024px. They are Tailwind's sm, md and lg, so no custom screens are configured.
sm 640md 768lg 1024xl 1280| Prefix | From | What it is for |
|---|---|---|
| none | 0 | The phone layout. Write it first; everything else adds to it. |
sm | 640px | Gutters widen to 24px, stage tracks show every label, dialogs take their fixed width with a row footer. |
md | 768px | The tablet line and useIsMobile. The sidebar becomes a rail, inputs drop to 14px, lists become tables and panes sit side by side. |
lg | 1024px | Rails appear beside content: Home's 20rem rail and the record page's 21rem attribute rail. |
xl | 1280px | Not used by the product. Canon's docs show the On this page rail from here. |
What changes at each width#
Every surface has a known way to fold. Read across a row to see one surface at every width.
| Surface | Below 640px | 640 to 767px | 768 to 1023px | From 1024px |
|---|---|---|---|---|
| Shell | Sidebar is an 18rem sheet behind the header's toggle. | Same as below 640px. | 16rem sidebar rail, collapsible to the 3rem icon rail. | Same as 768px. |
| Document pages | 16px gutters, 32px top and bottom. | 24px gutters. | Same; the column fills the plane. | Same; the column caps at 72rem. |
| Home | Rail cards stack above the approvals. | Same as below 640px. | Rail cards sit two-up above the approvals. | Approvals beside a 20rem rail. |
| Record pages | Attribute rail is a sheet behind a details button. Tabs scroll and fade. | Same as below 640px. | Same as below 640px. | 21rem attribute rail with a hairline left edge. |
| Full-bleed tools | Header, toolbar and one scrolling region still render. Canvases and editors show a notice. | Same as below 640px. | Full layout. | Full layout. |
| Three-pane workspaces | One pane at a time with a back affordance. | Same as below 640px. | Three resizable panes. | Three resizable panes. |
| Tables and lists | Stacked rows: the name and key figure on top, meta beneath. Secondary columns hide. | Same as below 640px. | Columns return. | All columns. |
| Stage track | Only the current stage's label shows; the rest stay for screen readers. | Every label. | Every label. | Every label. |
| Dialogs | Full width less 2rem, footer buttons stacked with the primary on top. | Fixed width, footer in a row aligned right. | Same as 640px. | Same as 640px. |
| Side sheets | 75% of the width; the product usually passes w-full. | Capped at 24rem unless the sheet sets its own width. | Same as 640px. | Same as 640px. |
| Toolbars | Wrap onto a second row; the search takes the full width. | Wrap as needed; the search returns to its width. | One row in most views. | One row. |
| Inputs | 16px text. | 16px text. | 14px text. | 14px text. |
See it fold#
Pick a width. The frame is drawn at that width and scaled to fit, and the classes inside respond to the frame the way the page responds to the window.
A record page and a workspace at four widths
At 390px, open a conversation in the workspace, then step back. The buttons inside the frame work at every width.
Below 640px: 16px gutters, the sidebar waits behind the menu button, the stage track shows only the current label, invoices become stacked rows and the attribute rail is a sheet behind the details button.
The demo uses container queries (@min-[768px]:) so it can fold inside this page. Product screens use the viewport breakpoints (md:) that the queries stand in for.
Inputs and hit targets#
Text fields are 16px below 768px. Every target is at least 24px, and 32px where the layout is meant for touch.
Targets larger than what they draw
A checkbox draws a 16px box and takes a 40 by 32px target through a transparent after element, so it stays small in a row without being hard to hit.
import { Button } from "@oration/canon/components/button";import { Checkbox } from "@oration/canon/components/checkbox";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import { MoreHorizontalIcon } from "lucide-react";import * as React from "react";export function HitTargets() { const [show, setShow] = React.useState<"controls" | "targets">("targets"); const [checked, setChecked] = React.useState(true); const outline = show === "targets" ? "pointer-events-none absolute rounded-md border border-dashed border-foreground/50" : "hidden"; return ( <div className="flex w-full flex-col items-center gap-6"> <SegmentedControl label="Hit target view" value={show} onValueChange={setShow} options={[ { value: "controls", label: "Controls" }, { value: "targets", label: "Show targets" }, ]} /> <div className="flex flex-wrap items-start justify-center gap-x-12 gap-y-8"> <div className="flex flex-col items-center gap-3"> <label className="relative flex items-center gap-2 text-13 text-foreground"> <span className="relative flex"> <Checkbox checked={checked} onCheckedChange={(value) => setChecked(value)} /> <span aria-hidden="true" className={cn(outline, "-inset-x-3 -inset-y-2")} /> </span> Include Halcyon </label> <span className="text-center text-xs text-muted-foreground"> 16px box, 40 by 32px target </span> </div> <div className="flex flex-col items-center gap-3"> <span className="relative flex"> <Button type="button" variant="ghost" size="icon-xs" aria-label="More actions for INV-20931" onClick={() => toast.add({ title: "Row actions", description: "INV-20931, Northwind Freight.", }) } > <MoreHorizontalIcon aria-hidden="true" /> </Button> <span aria-hidden="true" className={cn(outline, "inset-0")} /> </span> <span className="text-center text-xs text-muted-foreground"> 24px, the minimum </span> </div> <div className="flex flex-col items-center gap-3"> <span className="relative flex"> <Button type="button" variant="outline" onClick={() => toast.add({ title: "Export started", description: "212 invoices to CSV.", }) } > Export </Button> <span aria-hidden="true" className={cn(outline, "inset-0")} /> </span> <span className="text-center text-xs text-muted-foreground"> 32px, the touch minimum </span> </div> </div> </div> );}- Inputs, textareas and search fields are
text-base md:text-sm. Below 16px, mobile Safari zooms the page when a field takes focus. - 24px is the smallest target. Grow the hit area with padding or an
afterelement rather than drawing a bigger control. - Where a layout is for touch, controls are at least 32px; use the 36px large size in touch-first layouts.
The edge insert button on the Flow canvas is 20px, as DESIGN.md specifies, which is under the 24px minimum. The canvas shows the larger-screen notice on phones.
useIsMobile#
A hook for the few layouts whose structure changes on a phone, such as panes that become one. Everything else is CSS.
What the hook returns here
This window
- Width
- Measuring
useIsMobile()- false
Resize the window across 768px to see it flip.
import { useIsMobile } from "@oration/canon/hooks/use-mobile";import * as React from "react";export function IsMobileReadout() { const isMobile = useIsMobile(); const [width, setWidth] = React.useState<number | null>(null); React.useEffect(() => { const update = () => setWidth(window.innerWidth); update(); window.addEventListener("resize", update); return () => window.removeEventListener("resize", update); }, []); return ( <div className="flex w-full max-w-sm flex-col gap-3 rounded-xl bg-card p-4 shadow-border"> <p className="text-sm font-semibold text-foreground">This window</p> <dl className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1.5 text-13"> <dt className="text-muted-foreground">Width</dt> <dd className="text-right font-medium text-foreground tabular-nums"> {width === null ? "Measuring" : `${width}px`} </dd> <dt className="text-muted-foreground"> <code className="font-mono text-xs">useIsMobile()</code> </dt> <dd className="text-right font-mono text-xs leading-5 text-foreground"> {String(isMobile)} </dd> </dl> <p className="text-xs text-pretty text-muted-foreground"> Resize the window across 768px to see it flip. </p> </div> );}import { useIsMobile } from "@oration/canon/hooks/use-mobile";// true below 768px. Use it only when the structure differs, not for styling.function Inbox() { const isMobile = useIsMobile(); const [pane, setPane] = React.useState<"queue" | "conversation">("queue"); if (isMobile) { return pane === "queue" ? ( <Queue onOpen={() => setPane("conversation")} /> ) : ( <Conversation onBack={() => setPane("queue")} /> ); } return ( <ResizablePanelGroup orientation="horizontal">…three panes…</ResizablePanelGroup> );}- It returns
truebelow 768px and listens for changes, so a rotated tablet updates. - Prefer
md:classes. A breakpoint class works on the server render; the hook only knows the width after mount. - Reach for it when the tree itself differs: one pane instead of a resizable group, a sheet instead of a rail, or a mobile-only control.
useIsMobile returns false until mounted, so a phone renders the desktop structure first and swaps after hydration. There is no hook for 640 or 1024px.
Best on a larger screen#
Canvases and editors may say they work better on a bigger screen. They still render and navigate on a phone.
The notice in an editor toolbar
12px Slate Meta text with a 14px monitor icon at the end of the toolbar, shown only below 768px with md:hidden. It sits where the toolbar's secondary status would go.
import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import { MonitorIcon, PlayIcon, Redo2Icon, Undo2Icon } from "lucide-react";export function LargerScreenNotice() { return ( <div className="flex w-full max-w-[390px] flex-col overflow-hidden rounded-xl bg-card shadow-border"> <div className="flex h-11 items-center gap-1 border-b border-border px-2"> <Button type="button" variant="ghost" size="icon-sm" aria-label="Undo" onClick={() => toast.add({ title: "Undid the last change" }) } > <Undo2Icon aria-hidden="true" /> </Button> <Button type="button" variant="ghost" size="icon-sm" aria-label="Redo" onClick={() => toast.add({ title: "Redid the change" })} > <Redo2Icon aria-hidden="true" /> </Button> <span aria-hidden="true" className="mx-1 h-4 w-px bg-border" /> <span className="text-xs text-muted-foreground tabular-nums"> 6 steps </span> <span className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground"> <MonitorIcon aria-hidden="true" className="size-3.5" /> Best on a larger screen </span> </div> <div className="flex h-56 flex-col items-center justify-center gap-6 px-4" style={{ backgroundImage: "radial-gradient(circle at 0.6px 0.6px, var(--border-strong) 0.6px, transparent 0.6px)", backgroundSize: "18px 18px", }} > <div className="flex w-56 items-center gap-2.5 rounded-xl bg-card p-3 shadow-border"> <span className="flex size-8 items-center justify-center rounded-[10px] bg-muted"> <PlayIcon 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"> Trigger </span> <span className="truncate text-13 font-medium text-foreground"> Invoice received </span> </span> </div> </div> <div className="flex justify-end border-t border-border p-2"> <Button type="button" size="sm" onClick={() => toast.add({ type: "success", title: "Test run started", description: "Invoice approval, 6 steps.", }) } > Test run </Button> </div> </div> );}- Use it only on tool screens that need room to work, such as canvases and editors. The procedure editor is the reference.
- It is a note, not a wall. No dialog, no blocked page, no redirect.
- Everything on the screen stays reachable: open nodes, read settings, run a test.
Test widths#
Check every screen at 390, 768, 1024 and 1440px before calling it done.
| Width | Why | What to check |
|---|---|---|
| 390px | A current phone. | 16px gutters, no horizontal scroll, stacked rows, one pane, 16px inputs, readable stage track. |
| 768px | The md line, a tablet held upright. | The sidebar rail appears, tables return, workspaces show three panes. |
| 1024px | The lg line, a small laptop. | Home's rail and the record rail appear without crowding the main column. |
| 1440px | A desktop. | The document column stops at 72rem and centers; tools fill the width. |
// Gutters<div className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6">…</div>// Inputs: 16px below 768px so iOS doesn't zoom<input className="h-8 text-base md:text-sm" />// A list row that stacks below 768px and becomes columns from 768px<li className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1 md:grid-cols-[minmax(0,1fr)_7rem_7rem_6rem]">…</li>// The record rail from 1024px, a sheet below<aside className="hidden w-[21rem] border-l border-border lg:block">…</aside><Button variant="ghost" size="icon-sm" aria-label="Show details" className="lg:hidden">…</Button>// The notice on canvases and editors<span className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground md:hidden"> <MonitorIcon className="size-3.5" aria-hidden /> Best on a larger screen</span>