Presentation
Sheet
Slide a panel in from the edge of the screen, and let people drag it.
"use client"
import { Button } from "macbetter/button"
import { List, ListRow, ListRowTitle, ListRowValue, ListSection } from "macbetter/list"
import {
Sheet,
SheetBody,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "macbetter/sheet"
const places = [
{ name: "Blue Bottle Coffee", detail: "Coffee · 0.2 mi" },
{ name: "Tartine Bakery", detail: "Bakery · 0.4 mi" },
{ name: "Dolores Park", detail: "Park · 0.5 mi" },
{ name: "Mission Dolores", detail: "Landmark · 0.6 mi" },
{ name: "Ritual Coffee Roasters", detail: "Coffee · 0.7 mi" },
{ name: "Bi-Rite Creamery", detail: "Ice cream · 0.7 mi" },
{ name: "Alamo Square", detail: "Park · 1.1 mi" },
{ name: "Ferry Building", detail: "Market · 2.3 mi" },
]
export default function Example() {
return (
<Sheet detents={["medium", "large"]}>
<SheetTrigger render={<Button variant="filled" />}>Find a Place</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Places Nearby</SheetTitle>
<SheetClose variant="primary">Done</SheetClose>
</SheetHeader>
<SheetDescription>Drag the sheet up to see more.</SheetDescription>
<SheetBody>
<List>
<ListSection>
{places.map((place) => (
<ListRow key={place.name}>
<ListRowTitle>{place.name}</ListRowTitle>
<ListRowValue>{place.detail}</ListRowValue>
</ListRow>
))}
</ListSection>
</List>
</SheetBody>
</SheetContent>
</Sheet>
)
}When to use it
Use a bottom sheet on iPhone for supporting content that people look at alongside the page: results on a map, share options, a quick form. With detents, it can rest at half height so the page stays in view, and expand when people need more. Use a side sheet as an inspector on iPad and the Mac, for properties of the selection.
For a task that should take over until it’s done, use a Dialog. For a short choice, an Alert action sheet.
Usage
import {
Sheet,
SheetBody,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetOutlet,
SheetTitle,
SheetTrigger,
} from "macbetter/sheet"SheetContent brings its backdrop and the grabber. The sheet follows the finger one to one while it’s dragged, and a flick carries momentum: fast enough and it dismisses, or skips a detent. Long content goes in SheetBody, which scrolls first and hands the drag to the sheet once it reaches the top.
Detents
detents={["medium", "large"]} opens the sheet at half height and lets people drag it to nearly full height. With more than one detent, the grabber is a button: pressing it steps to the next detent, and arrow keys, Home and End choose one. With a single detent it’s a plain bar. For other resting points, pass ui-sdk’s snapPoints (fractions, pixels or "content").
Inspectors
side="end" (or "start") slides a panel in from the trailing edge. On the Mac and iPad it floats inset from the edge with rounded corners. Pass modal={false} to keep the page usable beside it.
"use client"
import { Button } from "macbetter/button"
import { List, ListRow, ListRowAccessory, ListRowTitle, ListRowValue, ListSection } from "macbetter/list"
import { Sheet, SheetBody, SheetClose, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "macbetter/sheet"
import { Switch } from "macbetter/switch"
import { PanelRightIcon } from "lucide-react"
export default function Example() {
return (
<Sheet side="end" modal={false}>
<SheetTrigger render={<Button />}>
<PanelRightIcon aria-hidden="true" />
Show Inspector
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Inspector</SheetTitle>
<SheetClose>Hide</SheetClose>
</SheetHeader>
<SheetBody>
<List>
<ListSection header="Document">
<ListRow>
<ListRowTitle>Size</ListRowTitle>
<ListRowValue>1,920 × 1,080</ListRowValue>
</ListRow>
<ListRow>
<ListRowTitle>Modified</ListRowTitle>
<ListRowValue>Today, 9:41</ListRowValue>
</ListRow>
<ListRow>
<ListRowTitle>Show Grid</ListRowTitle>
<ListRowAccessory>
<Switch aria-label="Show Grid" defaultChecked />
</ListRowAccessory>
</ListRow>
</ListSection>
</List>
</SheetBody>
</SheetContent>
</Sheet>
)
}The page behind
Wrap the page in SheetOutlet and it recedes as a large sheet rises, scaling down and rounding its corners as iOS does. Its keyframes run from closed to fully expanded.
- InèsThe proofs are back from the printer.
- KofiLunch on Thursday?
- WrenPushed the fix for the release build.
"use client"
import { Button } from "macbetter/button"
import {
Sheet,
SheetBody,
SheetClose,
SheetContent,
SheetHeader,
SheetOutlet,
SheetTitle,
SheetTrigger,
} from "macbetter/sheet"
import { Field, FieldLabel } from "macbetter/field"
import { TextField } from "macbetter/text-field"
const messages = [
{ from: "Inès", text: "The proofs are back from the printer." },
{ from: "Kofi", text: "Lunch on Thursday?" },
{ from: "Wren", text: "Pushed the fix for the release build." },
]
export default function Example() {
return (
<Sheet detents={["large"]}>
<SheetOutlet style={{ width: "100%", padding: 16, background: "var(--mb-bg)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<strong>Inbox</strong>
<SheetTrigger render={<Button variant="filled" size="sm" />}>Compose</SheetTrigger>
</div>
<ul style={{ listStyle: "none", padding: 0, margin: "12px 0 0", display: "grid", gap: 10 }}>
{messages.map((message) => (
<li key={message.from}>
<div style={{ fontWeight: 600 }}>{message.from}</div>
<div style={{ color: "var(--mb-label-secondary)" }}>{message.text}</div>
</li>
))}
</ul>
</SheetOutlet>
<SheetContent>
<SheetHeader>
<SheetClose>Cancel</SheetClose>
<SheetTitle>New Message</SheetTitle>
<SheetClose variant="primary" value="send">
Send
</SheetClose>
</SheetHeader>
<SheetBody>
<Field>
<FieldLabel>To</FieldLabel>
<TextField defaultValue="Design team" />
</Field>
</SheetBody>
</SheetContent>
</Sheet>
)
}Platform behavior
- iOS. The sheet spans the screen, with 30pt corners and a 36×5pt grabber. The large detent stops just under the status bar. A sheet holding a grouped List takes the grouped background from edge to edge, as Settings-style sheets do.
- iPadOS. A bottom sheet is a centered 620pt card; inspectors float 10pt in from the edge.
- macOS. Inspectors float 8pt in from the window edge with 16pt corners; a bottom sheet is a centered 560pt panel.
- A sheet opened from inside another pushes the first one back with the
--mb-spring-sheetspring. Reduced motion fades sheets in and out instead of sliding them.
Accessibility
- The content is a
dialog, named bySheetTitleand described bySheetDescription. While modal, it keeps Tab inside and makes the page inert. - Escape and a press on the dimmed page close it, and focus returns to the trigger.
- Opening moves focus to the first control in the content. The grabber, when it’s a button, comes last in the Tab order, is named “Resize sheet” and works with Enter, Space, the arrow keys, Home and End.
API
Sheet
Takes every ui-sdk Drawer root prop.
| Prop | Type | Default | Description |
|---|---|---|---|
side | "bottom" | "top" | "start" | "end" | "bottom" | The edge it comes from. |
detents | ("medium" | "large")[] | — | Where it rests: medium is half the screen, large nearly all of it. |
snapPoints | SnapPoint[] | — | Resting points of your own. Wins over detents. |
modal | boolean | "trap-focus" | true | false leaves the page usable beside it. |
modalAbove | SnapPoint | — | Modal only above this detent, like Maps. |
dismissible | boolean | true | Whether a swipe, Escape or an outside press can close it. |
SheetContent
| Prop | Type | Default | Description |
|---|---|---|---|
grabber | boolean | true | Shows the grabber on a bottom sheet. |
SheetClose
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "primary" | "default" | primary is bold, like Done. |
value | string | — | Reported as returnValue. |
SheetOutlet
| Prop | Type | Default | Description |
|---|---|---|---|
keyframes | SheetKeyframes | { scale: [1, 0.94], borderRadius: ["0px", "12px"] } | What the page does as the sheet expands. |
SheetTrigger, SheetHeader, SheetTitle, SheetDescription and SheetBody take their element’s props. createSheetController is ui-sdk’s createDrawerController.
| Data attribute | On | Meaning |
|---|---|---|
data-side | .mb-sheet | The physical edge, with start and end resolved |
data-snap-point | .mb-sheet | The detent it rests on |
data-expanded | .mb-sheet | Resting at its largest detent |
data-dragging | .mb-sheet | Being dragged |
data-large | .mb-sheet | Has a large detent, so it's sized to nearly fill the screen |
| CSS variable | Default |
|---|---|
--mb-sheet-width | 560px macOS, 620px iPadOS, 440px iOS |
--mb-sheet-inspector-width | 300px macOS, 340px iPadOS |
--mb-sheet-top-gap | The space left above a large sheet |
--drawer-progress, --drawer-expand, --drawer-stack | Set by ui-sdk while it moves |