macbetter

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.

iOS only
Inbox
  • Inès
    The proofs are back from the printer.
  • Kofi
    Lunch on Thursday?
  • Wren
    Pushed 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-sheet spring. Reduced motion fades sheets in and out instead of sliding them.

Accessibility

  • The content is a dialog, named by SheetTitle and described by SheetDescription. 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.

PropTypeDefaultDescription
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.
snapPointsSnapPoint[]—Resting points of your own. Wins over detents.
modalboolean | "trap-focus"truefalse leaves the page usable beside it.
modalAboveSnapPoint—Modal only above this detent, like Maps.
dismissiblebooleantrueWhether a swipe, Escape or an outside press can close it.

SheetContent

PropTypeDefaultDescription
grabberbooleantrueShows the grabber on a bottom sheet.

SheetClose

PropTypeDefaultDescription
variant"default" | "primary""default"primary is bold, like Done.
valuestring—Reported as returnValue.

SheetOutlet

PropTypeDefaultDescription
keyframesSheetKeyframes{ 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 attributeOnMeaning
data-side.mb-sheetThe physical edge, with start and end resolved
data-snap-point.mb-sheetThe detent it rests on
data-expanded.mb-sheetResting at its largest detent
data-dragging.mb-sheetBeing dragged
data-large.mb-sheetHas a large detent, so it's sized to nearly fill the screen
CSS variableDefault
--mb-sheet-width560px macOS, 620px iPadOS, 440px iOS
--mb-sheet-inspector-width300px macOS, 340px iPadOS
--mb-sheet-top-gapThe space left above a large sheet
--drawer-progress, --drawer-expand, --drawer-stackSet by ui-sdk while it moves