Mobile breakpoint
Reports whether the viewport is below 768px.
useIsMobile() is false. Narrow the window below 768px to swap the panel for a sheet.
import { Button } from "@oration/canon/components/button";import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger,} from "@oration/canon/components/sheet";import { useIsMobile } from "@oration/canon/hooks/use-mobile";export function Hero() { const isMobile = useIsMobile(); const details = ( <dl className="grid grid-cols-[6.5rem_1fr] gap-x-3 gap-y-1.5 text-13"> <dt className="text-muted-foreground">Payment terms</dt> <dd className="text-foreground">Net 30</dd> <dt className="text-muted-foreground">Method</dt> <dd className="text-foreground">ACH</dd> <dt className="text-muted-foreground">Open balance</dt> <dd className="text-foreground tabular-nums">$42,180.00</dd> <dt className="text-muted-foreground">W-9</dt> <dd className="text-foreground">Received Mar 3, 2026</dd> </dl> ); return ( <div className="flex w-full max-w-xl flex-col gap-3"> <p className="text-13 text-muted-foreground"> <code className="font-mono text-xs text-foreground"> useIsMobile() </code>{" "} is{" "} <code className="font-mono text-xs text-foreground"> {String(isMobile)} </code> . Narrow the window below 768px to swap the panel for a sheet. </p> <div className="flex items-start gap-4 rounded-xl bg-card p-4 shadow-border"> <div className="flex min-w-0 flex-1 flex-col gap-1"> <span className="text-sm font-medium text-foreground"> Northwind Freight </span> <span className="text-13 text-muted-foreground"> 14 open invoices, 9 in Friday's payment run </span> </div> {isMobile ? ( <Sheet> <SheetTrigger render={ <Button type="button" variant="outline" size="sm" /> } > Supplier details </SheetTrigger> <SheetContent side="bottom"> <SheetHeader> <SheetTitle>Northwind Freight</SheetTitle> <SheetDescription> Supplier since 2021 </SheetDescription> </SheetHeader> <div className="px-4 pb-6">{details}</div> </SheetContent> </Sheet> ) : ( <aside aria-label="Supplier details" className="w-60 shrink-0 border-l border-border pl-4" > {details} </aside> )} </div> </div> );}Usage#
useIsMobile reports whether the viewport is narrower than 768px, the suite's tablet breakpoint, and updates when it crosses. The Sidebar uses it to become a sheet, and the copilot panel, contact center workspace, macros view and web call widget use it to change structure. It returns false on the server and on the first client render, so it can only change what renders after hydration. Anything CSS can express belongs in an md: class instead.
When to use
- Swapping structure that CSS can't: a side panel becomes a sheet, a docked rail becomes a drawer.
- Choosing a different overlay or interaction on phones, such as a bottom sheet instead of a popover.
- Deciding a default, such as starting a panel collapsed below 768px.
When not to use
- Spacing, columns, font sizes or visibility. Use
md:andmax-md:classes, which apply before hydration with no flash. Use Responsive - Other breakpoints (640 or 1024px). The hook is fixed at 768px; use CSS or a
matchMediaof your own. Use Layout - Detecting touch. Width says nothing about input; use
(pointer: coarse)or(hover: none). - Rendering on the server. It's always
falsethere. - The app sidebar. It already switches to a sheet below 768px. Use Sidebar
Breakpoints
useIsMobile is the 768px one, and the only one with a hook.Examples#
When CSS is enough
These stats stack below 768px and sit three across above it with md: classes alone. No hook, so the first paint is already right on a phone.
- Open invoices
- 1,284
- Due this week
- $1,284,310.42
- Exceptions
- 12
export function CssFirst() { const stats = [ { label: "Open invoices", value: "1,284" }, { label: "Due this week", value: "$1,284,310.42" }, { label: "Exceptions", value: "12" }, ]; return ( <dl className="grid w-full max-w-xl grid-cols-1 gap-2 md:grid-cols-3"> {stats.map((stat) => ( <div key={stat.label} className="flex items-baseline justify-between gap-2 rounded-xl bg-card px-4 py-3 shadow-border md:flex-col md:items-start" > <dt className="text-13 text-muted-foreground"> {stat.label} </dt> <dd className="text-base font-medium text-foreground tabular-nums"> {stat.value} </dd> </div> ))} </dl> );}States#
| State | Treatment |
|---|---|
| Server and first render | false, whatever the device. The real value arrives in an effect after hydration. |
| Desktop | false at 768px and wider. |
| Mobile | true below 768px. |
| Crossing | Resizing, rotating or zooming across 768px re-renders with the new value. |
Behavior#
- Listens to
matchMedia("(max-width: 767px)")and readswindow.innerWidthwhen it changes, so it measures the viewport, not the component's container. - Browser zoom changes the CSS viewport width: a desktop user at 200% zoom on a 1280px window gets the mobile structure.
- Every component that calls it adds its own listener; there is no shared store.
Do and don't#
md: classes for anything visual: padding, columns, hidden labels.useIsMobile() for styling. Phones render the desktop layout first and then jump.Content#
- When a panel becomes a sheet, keep its title, description and action labels identical, so the content reads the same at both sizes.
- The trigger that opens the mobile sheet names what it opens: Supplier details, not More.
Accessibility#
- A structure swap remounts content. Don't swap while a person is typing or has focus inside the panel, or focus is lost.
- Zoomed desktop users get the mobile layout, so it has to be complete, not a reduced version.
- Touch layouts keep hit areas at 32px or more, per the control sizes for touch surfaces.
- Sheets that replace panels need a title (
SheetTitle) so the dialog has a name.
API reference#
useIsMobile
useIsMobile(): boolean, from @oration/canon/hooks/use-mobile. Takes no arguments.
No props of its own.
Returns
| Prop | Type | Default | Description |
|---|---|---|---|
isMobile | boolean | No default | True when window.innerWidth is below 768. False on the server and before the first effect. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
It returns false until mounted, so on phones every caller renders its desktop structure first and swaps after hydration.
The 768px breakpoint isn't exported and there is no hook for 640 or 1024px, so the copilot panel defines its own useWideScreen alongside it.