Guide
Mobile & responsive
karaUI components are built to work on phones without extra wiring: layouts follow the space they are given, small controls stay easy to tap, and wide surfaces like tables and dashboards switch to a layout that fits. This page covers what happens automatically and the few props that control it.
How components respond
Tailwind CSS 4's viewport breakpoints (sm:, md:, lg:…) are available as-is in your own markup. Most blocks, though, use container queries: they mark their root with @container and lay out with @md:, @xl: and friends. They adapt to the width of their own box, not the window — so a pricing table in a sidebar, a split view or a docs frame gets its narrow layout even on a wide monitor.
// Viewport breakpoints work as usual (sm: md: lg: …).
// Container queries respond to the nearest @container instead:
<aside className="@container w-80">
<div className="grid gap-3 @lg:grid-cols-2">
{/* one column in this 320px aside, two once it is ≥ 32rem wide */}
</div>
</aside>A few components measure themselves in JavaScript instead, for the same reason: Carousel takes slidesPerView as a map keyed by its own width ({ 0: 1, 480: 2, 768: 3 }), and the tables below stack by their own width. Overlays that cover the screen (Dialog, Navigation Menu, Animated Sidebar) follow the viewport.
Touch targets
On coarse pointers (phones, tablets) small controls get an invisible hit area of at least 44×44px (WCAG 2.5.5, Apple HIG). It is an ::after box centered on the control, so the control looks and lays out the same, and mouse and trackpad rendering is unchanged. These components have it built in:
- Button — every size, including StatefulButton
- Checkbox
- Radio Group
- Switch
- Toggle / ToggleGroup
- Select — trigger
- Tabs — pill and segment triggers
- Pagination — dots grow vertically only
- Number Stepper — ± buttons
- Copy Button
Apply the same behavior to your own controls with TOUCH_TARGET_CLASS from lib/touch.ts (it ships with any component that uses it, such as Button). Use TOUCH_TARGET_Y_CLASS for controls packed closer than about 32px apart in a row: it grows the hit area vertically only, so neighbours don't take over each other's taps.
import type { ComponentProps } from "react";
import { TOUCH_TARGET_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
// A 32px icon button that still gets a 44×44px hit area on touch screens.
export function IconButton({ className, ...props }: ComponentProps<"button">) {
return (
<button
type="button"
className={cn(
// relative: the invisible ::after box is positioned against it.
"relative inline-grid size-8 place-items-center rounded-md",
TOUCH_TARGET_CLASS,
className,
)}
{...props}
/>
);
}The element it goes on must be relative (the containing block), must not be overflow-hidden (the overhang would be clipped), must render children (<input> and other void elements have no ::after — put it on the wrapper or label), and must not already use ::after for something visible. Inside a scroller, a control flush against the edge can add a few pixels of scroll on touch screens; pad that side.
Input and Textarea use 16px text, so iOS Safari doesn't zoom the page when they get focus.
Tables on phones
Table and DataTable scroll sideways by default (mobileLayout="scroll"). With mobileLayout="stack", whenever the table's own width is below stackBelow (default 560px) each row becomes a card: primary columns (the first column if none is flagged) form the title, and the rest render as header/value pairs. hideOnMobile leaves a column out of the cards.
import { Table, type TableColumn } from "@/components/motion/table";
type Invoice = {
id: string;
customer: string;
email: string;
status: string;
amount: number;
};
const columns: TableColumn<Invoice>[] = [
// Card title. Without any `primary` flag the first column is used.
{ key: "customer", header: "Customer", primary: true, sortable: true },
{ key: "status", header: "Status" },
{
key: "amount",
header: "Amount",
align: "right",
sortable: true,
cell: (row) =>
row.amount.toLocaleString("en-US", { style: "currency", currency: "USD" }),
},
// Shown in the table, left out of the cards.
{ key: "email", header: "Email", hideOnMobile: true },
];
export function Invoices({ data }: { data: Invoice[] }) {
return (
<Table
data={data}
columns={columns}
getRowId={(row) => row.id}
mobileLayout="stack"
stackBelow={600} // px of the table's own width (default 560)
/>
);
}The stacked list keeps the table's features: a select-all checkbox and a native sort select sit above the cards. Table stays virtualized, with editable cells and a per-card row menu; DataTable adds filter chips that open the column menu and keeps row expansion and Shift-range selection.
import {
DataTable,
type DataTableColumn,
} from "@/components/motion/table/data-table";
// Same column flags, plus DataTable's filter options.
const columns: DataTableColumn<Invoice>[] = [
{ key: "customer", header: "Customer", primary: true, sortable: true },
{ key: "status", header: "Status", filter: "select" },
{ key: "email", header: "Email", hideOnMobile: true },
];
<DataTable data={data} columns={columns} selectable mobileLayout="stack" />The server always renders the table; the switch to cards happens after hydration, before the first paint, and then follows the table's width as it resizes.
Navigation and overlays
These change shape on small screens or in narrow containers. Breakpoints written as sm/md are viewport-based; ones written as @md/@xl are container queries.
- Navigation Menu
Below the viewport
mdbreakpoint the top items collapse into a hamburger that opens a sheet with accordion sections. - Animated Sidebar
Below
md(viewport) the provider switches to a focus-managed mobile sheet; the same toggle opens it. - Dashboard Shell
Container-aware: when the shell itself is narrower than
mobileBelow(default768) a hamburger opens the nav in a left Drawer, which closes again if the shell widens. Done with a scoped CSS container query, so it is server-rendered with no hydration swap. - Dialog
Becomes a bottom-anchored sheet below the viewport
smbreakpoint.sheetOnMobiledefaults totrue. - Bottom Sheet
A draggable sheet with
snapPoints(default[0.5, 0.92]) and fling-to-dismiss, for phone-first overlays. - Drawer
A
side"left"or"right"panel for off-canvas navigation and filters. - Footer Section
Link columns become disclosures below the container's
@mdwidth. - Comparison Table
Wide (
@xl) it is a full plan × feature grid; narrower it shows a plan switcher and one value column that cross-fades. - Contact Form
Name and email sit side by side from
@lg; the contact methods move from a grid under the form to a side column at@3xl. - Checkout
The order summary stacks under the steps and moves beside them at
@xl.
// Below sm, DialogContent is a bottom sheet by default.
// Opt out to keep the centered panel on phones:
<DialogContent sheetOnMobile={false}>…</DialogContent>Other blocks follow the same container-query pattern — Pricing Table, Bento Grid, Settings Page and Inbox App among them — so check a component's preview at a narrow width to see its small layout.
Gestures
These components handle touch gestures directly (mouse drag works too), and opt out of iOS's long-press callout and selection where the surface owns the gesture:
- Carousel
Pan drag with momentum, snap and rubber-band edges.
- Swipeable List
Swipe rows left or right to reveal actions.
- Pull to Refresh
Pull with resistance and a threshold, then an async refresh.
- Bottom Sheet
Drag between snap points with inertia; fling down to dismiss.
- Kanban Board
Touch drag starts after a ~220ms still press, so swipes still scroll the columns.
- Wheel Picker
iOS-style drum with momentum coast and spring-back settle.