Skip to content

Mobile breakpoint

Reports whether the viewport is below 768px.

Status
Beta
Level
Utility
Category
Utilities
Adoption
Not used yet
import { useIsMobile } from "@oration/canon/hooks/use-mobile";
packages/canon/src/hooks/use-mobile.ts

useIsMobile() is false. Narrow the window below 768px to swap the panel for a sheet.

Northwind Freight14 open invoices, 9 in Friday's payment run
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&apos;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: and max-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 matchMedia of your own. Use Layout
  • Detecting touch. Width says nothing about input; use (pointer: coarse) or (hover: none).
  • Rendering on the server. It's always false there.
  • The app sidebar. It already switches to a sheet below 768px. Use Sidebar

Breakpoints

Breakpoints are 640, 768 and 1024px. 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#

States
StateTreatment
Server and first renderfalse, whatever the device. The real value arrives in an effect after hydration.
Desktopfalse at 768px and wider.
Mobiletrue below 768px.
CrossingResizing, rotating or zooming across 768px re-renders with the new value.

Behavior#

  • Listens to matchMedia("(max-width: 767px)") and reads window.innerWidth when 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#

Do. Use md: classes for anything visual: padding, columns, hidden labels.
Don't. Branch JSX on useIsMobile() for styling. Phones render the desktop layout first and then jump.
Do. Use the hook to swap a structure, such as a side panel for a sheet, and keep the same title and actions in both.
Don't. Show different content or fewer actions on mobile. People expect the same record at both sizes.

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

Props of Returns
PropTypeDefaultDescription
isMobilebooleanNo defaultTrue 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.