Navigation
Device Frame
Present an iPhone or iPad layout inside a page.
Today
- Book the offsite venue
- Send Q3 metrics to Kofi
- Review the onboarding flow
import { DeviceFrame } from "macbetter/device-frame"
const reminders = ["Book the offsite venue", "Send Q3 metrics to Kofi", "Review the onboarding flow"]
export default function Example() {
return (
<DeviceFrame scale={0.78} role="group" aria-label="Reminders on iPhone">
<div
style={{
display: "flex",
flexDirection: "column",
paddingTop: "calc(var(--mb-safe-top) + 8px)",
paddingBottom: "var(--mb-safe-bottom)",
background: "var(--mb-bg-grouped)",
}}
>
<h2 className="mb-text-large-title" style={{ margin: 0, padding: "0 16px 8px" }}>
Today
</h2>
<ul
style={{
listStyle: "none",
margin: "0 16px",
padding: 0,
borderRadius: 10,
background: "var(--mb-bg-grouped-secondary)",
}}
>
{reminders.map((reminder, index) => (
<li
key={reminder}
className="mb-text-body"
style={{
padding: "11px 16px",
boxShadow: index ? "inset 0 0.5px 0 var(--mb-separator)" : undefined,
}}
>
{reminder}
</li>
))}
</ul>
</div>
</DeviceFrame>
)
}When to use it
Use a device frame to show an iPhone or iPad layout on a page people read on a computer: a product tour, a design review, or a live preview beside a settings panel. The screen inside the frame switches to the iOS or iPadOS platform, so every macbetter component in it takes the touch metrics on its own.
The frame is presentation, not emulation. It draws the hardware and publishes its safe areas; it doesn't rotate, scroll or respond to the home indicator. On a real phone, render your layout without a frame: the same safe-area tokens then read the device's own insets.
To present a Mac app, use a window.
Usage
import { DeviceFrame } from "macbetter/device-frame"iPhone
The default is an iPhone 16: a 393 × 852 point screen with the Dynamic Island, status bar and home indicator. Its child fills the screen. Use scale to fit it into a column; 0.78 fits it into a 390 point preview.
Today
- Book the offsite venue
- Send Q3 metrics to Kofi
- Review the onboarding flow
import { DeviceFrame } from "macbetter/device-frame"
const reminders = ["Book the offsite venue", "Send Q3 metrics to Kofi", "Review the onboarding flow"]
export default function Example() {
return (
<DeviceFrame scale={0.78} role="group" aria-label="Reminders on iPhone">
<div
style={{
display: "flex",
flexDirection: "column",
paddingTop: "calc(var(--mb-safe-top) + 8px)",
paddingBottom: "var(--mb-safe-bottom)",
background: "var(--mb-bg-grouped)",
}}
>
<h2 className="mb-text-large-title" style={{ margin: 0, padding: "0 16px 8px" }}>
Today
</h2>
<ul
style={{
listStyle: "none",
margin: "0 16px",
padding: 0,
borderRadius: 10,
background: "var(--mb-bg-grouped-secondary)",
}}
>
{reminders.map((reminder, index) => (
<li
key={reminder}
className="mb-text-body"
style={{
padding: "11px 16px",
boxShadow: index ? "inset 0 0.5px 0 var(--mb-separator)" : undefined,
}}
>
{reminder}
</li>
))}
</ul>
</div>
</DeviceFrame>
)
}iPad
Set device="ipad" for an 11-inch iPad Air, landscape by default. Its status bar shows the date beside the time. Set orientation="portrait" to turn it.
Today
3 reminders
import { DeviceFrame } from "macbetter/device-frame"
import { Sidebar, SidebarItem, SidebarSection } from "macbetter/sidebar"
import { CalendarIcon, InboxIcon, StarIcon, SunIcon } from "lucide-react"
export default function Example() {
return (
<DeviceFrame device="ipad" scale={0.56} role="group" aria-label="Reminders on iPad">
<div style={{ display: "flex", minHeight: 0 }}>
<Sidebar
aria-label="Lists"
style={{ width: 320, paddingTop: "calc(var(--mb-safe-top) + 16px)", paddingBottom: "var(--mb-safe-bottom)" }}
>
<SidebarSection title="Reminders">
<SidebarItem href="#today" icon={<SunIcon />} badge={3} current>
Today
</SidebarItem>
<SidebarItem href="#scheduled" icon={<CalendarIcon />} badge={7}>
Scheduled
</SidebarItem>
<SidebarItem href="#flagged" icon={<StarIcon />} tint="var(--mb-orange)">
Flagged
</SidebarItem>
<SidebarItem href="#all" icon={<InboxIcon />} badge={24}>
All
</SidebarItem>
</SidebarSection>
</Sidebar>
<div style={{ flex: 1, padding: "calc(var(--mb-safe-top) + 16px) 32px var(--mb-safe-bottom)" }}>
<h2 className="mb-text-large-title" style={{ margin: 0 }}>
Today
</h2>
<p className="mb-text-body mb-label-secondary">3 reminders</p>
</div>
</div>
</DeviceFrame>
)
}Safe areas
The screen publishes how much of each edge the hardware covers, as the same --mb-safe-* tokens that read env(safe-area-inset-*) on a real device. Pad your content with them and it clears the status bar and home indicator both inside a frame and on a phone:
.screen-header {
padding-top: calc(var(--mb-safe-top) + 8px);
}
.screen-footer {
padding-bottom: var(--mb-safe-bottom);
}macbetter's bars do this for you, and leave insets of their own for the content beneath them:
| Variable | Set by | Read by |
|---|---|---|
--mb-safe-top, --mb-safe-bottom, --mb-safe-left, --mb-safe-right | tokens.css (from env()), overridden by the device frame's screen | Navigation bar, tab bar, sheets, your own layouts |
--mb-content-inset-top | NavigationStack | List section headers, which stick below the bar instead of under it |
--mb-content-inset-bottom | TabBar | Navigation stack and scrolling content, which leave room for the bar |
The screen also sets --mb-safe-area-top and --mb-safe-area-bottom to the same values for code written before the shared tokens. Use --mb-safe-* in new code.
Platform behavior
- iPhone: the screen nests
data-platform="ios". The top inset is 59 points (the status bar and Dynamic Island), the bottom 34 (the home indicator). - iPad: the screen nests
data-platform="ipados". The top inset is 24 points and the bottom 20. - The frame draws the same on every page platform, so an iPhone preview can sit inside a Mac page with the right type sizes, touch targets and controls.
Accessibility
- The status bar, Dynamic Island and home indicator are hidden from assistive technology.
- Give the frame
role="group"and anaria-labelsuch as "Mail on iPhone", so people know the content inside is a preview. It matters most when a page has several. scaleuses CSSzoom, so text inside stays crisp and hit targets scale with it.
API
DeviceFrame
| Prop | Type | Default | Description |
|---|---|---|---|
device | "iphone" | "ipad" | "iphone" | Which device to draw. Its screen nests the iOS or iPadOS platform. |
orientation | "portrait" | "landscape" | portrait on iPhone, landscape on iPad | The iPad's orientation. iPhone frames are portrait. |
scale | number | 1 | Scales the whole device. 1 is actual size in points. |
time | string | "9:41" | The status bar's clock. |
date | string | "Mon Jun 9" | The date beside the clock on iPad. |
statusBar | boolean | true | Shows the time, signal, Wi-Fi and battery. |
homeIndicator | boolean | true | Shows the home indicator. |
It also takes the props of a div.
Data attributes and CSS variables
| Name | On | Description |
|---|---|---|
data-device | .mb-device-frame | iphone or ipad. |
data-orientation | .mb-device-frame | portrait or landscape. |
--mb-device-scale | .mb-device-frame | Set by scale. |
--mb-device-screen-width, --mb-device-screen-height | .mb-device-frame | The screen's size in points. Override both to draw another model. |
--mb-device-screen-radius | .mb-device-frame | The screen's corner radius. |
--mb-device-safe-top, --mb-device-safe-bottom | .mb-device-frame | The hardware insets the screen publishes. |
--mb-safe-top, --mb-safe-bottom, --mb-safe-left, --mb-safe-right | .mb-device-frame-screen | The shared safe-area tokens, set from the device. |