Skip to content

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 640
md 768
lg 1024
xl 1280
390
768
1024
1440
BreakpointTest widthThe xl breakpoint only shows the docs page rail.
Breakpoints
PrefixFromWhat it is for
none0The phone layout. Write it first; everything else adds to it.
sm640pxGutters widen to 24px, stage tracks show every label, dialogs take their fixed width with a row footer.
md768pxThe tablet line and useIsMobile. The sidebar becomes a rail, inputs drop to 14px, lists become tables and panes sit side by side.
lg1024pxRails appear beside content: Home's 20rem rail and the record page's 21rem attribute rail.
xl1280pxNot 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.

What changes at each breakpoint
SurfaceBelow 640px640 to 767px768 to 1023pxFrom 1024px
ShellSidebar 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 pages16px gutters, 32px top and bottom.24px gutters.Same; the column fills the plane.Same; the column caps at 72rem.
HomeRail cards stack above the approvals.Same as below 640px.Rail cards sit two-up above the approvals.Approvals beside a 20rem rail.
Record pagesAttribute 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 toolsHeader, toolbar and one scrolling region still render. Canvases and editors show a notice.Same as below 640px.Full layout.Full layout.
Three-pane workspacesOne pane at a time with a back affordance.Same as below 640px.Three resizable panes.Three resizable panes.
Tables and listsStacked rows: the name and key figure on top, meta beneath. Secondary columns hide.Same as below 640px.Columns return.All columns.
Stage trackOnly the current stage's label shows; the rest stay for screen readers.Every label.Every label.Every label.
DialogsFull 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 sheets75% 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.
ToolbarsWrap 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.
Inputs16px 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.

16px box, 40 by 32px target
24px, the minimum
32px, the touch minimum
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 after element 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>    );}
Swapping structure below 768px
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 true below 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.

6 stepsBest on a larger screen
TriggerInvoice received
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.

Test widths
WidthWhyWhat to check
390pxA current phone.16px gutters, no horizontal scroll, stacked rows, one pane, 16px inputs, readable stage track.
768pxThe md line, a tablet held upright.The sidebar rail appears, tables return, workspaces show three panes.
1024pxThe lg line, a small laptop.Home's rail and the record rail appear without crowding the main column.
1440pxA desktop.The document column stops at 72rem and centers; tools fill the width.
Responsive classes in use
// 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>