Navigation
Page Control
Show how many pages there are and which one is on screen.
Ask questions about your data in plain words.
"use client"
import { Button } from "macbetter/button"
import { PageControl } from "macbetter/page-control"
import { CloudIcon, LockIcon, SparklesIcon, ZapIcon } from "lucide-react"
import { useState } from "react"
const pages = [
{ icon: SparklesIcon, title: "Welcome to macbetter", body: "Ask questions about your data in plain words." },
{ icon: ZapIcon, title: "Answers in seconds", body: "Every chart updates as new rows arrive." },
{ icon: LockIcon, title: "Private by design", body: "Your data stays in your own database." },
{ icon: CloudIcon, title: "Works everywhere", body: "Pick up on your Mac right where you left off." },
]
export default function Example() {
const [page, setPage] = useState(1)
const { icon: Icon, title, body } = pages[page - 1]
return (
<div style={{ display: "grid", justifyItems: "center", gap: 20, width: "100%", maxWidth: 320, textAlign: "center" }}>
<Icon aria-hidden="true" style={{ width: 56, height: 56, color: "var(--mb-accent)" }} />
<div style={{ display: "grid", gap: 6, minHeight: 96 }}>
<div className="mb-text-title-2">{title}</div>
<p className="mb-text-body mb-label-secondary" style={{ margin: 0 }}>{body}</p>
</div>
<PageControl aria-label="Welcome pages" pageCount={pages.length} page={page} onPageChange={setPage} />
<Button variant="filled" block onClick={() => setPage((current) => Math.min(current + 1, pages.length))}>
{page === pages.length ? "Get Started" : "Continue"}
</Button>
</div>
)
}When to use it
Use a page control under a set of flat, equal pages that people swipe through, like the screens of an onboarding flow or the photos in a carousel. The dots say how many pages there are and where you are; they're a secondary way to move, not the main one.
Keep the set short. Past about ten pages the dots stop being countable at a glance, and a page count or a list reads better. Center the control under the content it belongs to, and don't use it for pages that have an order people need to follow step by step; use a stepper or a progress indicator there.
Usage
import { PageControl } from "macbetter/page-control"Plain
Pass pageCount, then control the page with page and onPageChange, or start one with defaultPage. Keep the content and the dots in step.
Ask questions about your data in plain words.
"use client"
import { Button } from "macbetter/button"
import { PageControl } from "macbetter/page-control"
import { CloudIcon, LockIcon, SparklesIcon, ZapIcon } from "lucide-react"
import { useState } from "react"
const pages = [
{ icon: SparklesIcon, title: "Welcome to macbetter", body: "Ask questions about your data in plain words." },
{ icon: ZapIcon, title: "Answers in seconds", body: "Every chart updates as new rows arrive." },
{ icon: LockIcon, title: "Private by design", body: "Your data stays in your own database." },
{ icon: CloudIcon, title: "Works everywhere", body: "Pick up on your Mac right where you left off." },
]
export default function Example() {
const [page, setPage] = useState(1)
const { icon: Icon, title, body } = pages[page - 1]
return (
<div style={{ display: "grid", justifyItems: "center", gap: 20, width: "100%", maxWidth: 320, textAlign: "center" }}>
<Icon aria-hidden="true" style={{ width: 56, height: 56, color: "var(--mb-accent)" }} />
<div style={{ display: "grid", gap: 6, minHeight: 96 }}>
<div className="mb-text-title-2">{title}</div>
<p className="mb-text-body mb-label-secondary" style={{ margin: 0 }}>{body}</p>
</div>
<PageControl aria-label="Welcome pages" pageCount={pages.length} page={page} onPageChange={setPage} />
<Button variant="filled" block onClick={() => setPage((current) => Math.min(current + 1, pages.length))}>
{page === pages.length ? "Get Started" : "Continue"}
</Button>
</div>
)
}Glass
variant="glass" puts the dots on a capsule of glass with white dots, so they stay legible over photos and busy backgrounds.
"use client"
import { PageControl } from "macbetter/page-control"
import { useState } from "react"
const photos = [
{ caption: "Sunrise over Half Dome", background: "linear-gradient(160deg, var(--mb-orange), var(--mb-pink) 60%, var(--mb-indigo))" },
{ caption: "Kelp forest, Monterey", background: "linear-gradient(160deg, var(--mb-mint), var(--mb-teal) 50%, var(--mb-blue))" },
{ caption: "Lavender fields, Valensole", background: "linear-gradient(160deg, var(--mb-purple), var(--mb-indigo) 60%, var(--mb-blue))" },
{ caption: "Aspens in October", background: "linear-gradient(160deg, var(--mb-yellow), var(--mb-orange) 55%, var(--mb-brown))" },
{ caption: "Moss in the rain", background: "linear-gradient(160deg, var(--mb-green), var(--mb-mint) 60%, var(--mb-teal))" },
]
export default function Example() {
const [page, setPage] = useState(1)
const photo = photos[page - 1]
return (
<figure
style={{
position: "relative",
display: "grid",
gridTemplateRows: "1fr auto",
justifyItems: "center",
margin: 0,
width: "100%",
maxWidth: 340,
aspectRatio: "4 / 5",
padding: 14,
borderRadius: 28,
background: photo.background,
transition: "background 300ms",
}}
>
<figcaption
className="mb-text-headline"
style={{ justifySelf: "start", color: "#fff", textShadow: "0 1px 8px rgb(0 0 0 / 0.3)" }}
>
{photo.caption}
</figcaption>
<PageControl variant="glass" aria-label="Photos" pageCount={photos.length} page={page} onPageChange={setPage} />
</figure>
)
}Platform behavior
- iOS and iPadOS: 8pt dots on a 17pt pitch, with a tap target that reaches the full height of the control.
- macOS: slightly smaller 7px dots, with a hover state under the pointer.
- The current dot is the label color, the others 30% of it, so the control follows light and dark and any theme.
Accessibility
- The control is a
navigationlandmark with a list of buttons, from ui-sdk Pagination. Name it witharia-label, like "Welcome pages". - Each dot is named "Page 2 of 4", and the current one has
aria-current="page". - Enter or Space on a focused dot goes to its page. Each dot is a tab stop, so keep the count small.
API
PageControl
| Prop | Type | Default | Description |
|---|---|---|---|
pageCount | number | — | How many pages there are. Required. |
page | number | — | The current page, from 1, to control it. |
defaultPage | number | 1 | The page it starts on, when uncontrolled. |
onPageChange | (page, details) => void | — | Runs when a dot is pressed. `details.cancel()` keeps the page. |
variant | "plain" | "glass" | "plain" | Bare dots, or dots on a glass capsule. |
getHref | (page) => string | — | Makes each dot a link to its page's URL. |
Data attributes and CSS variables
| Attribute or variable | On | Meaning |
|---|---|---|
data-variant | .mb-page-control | plain or glass |
data-current | .mb-page-control-dot | The dot of the page on screen |
--mb-page-control-dot | inherited | Diameter of a dot |
--mb-page-control-spacing | inherited | Distance between dot centers |
--mb-page-control-current, --mb-page-control-other | .mb-page-control | Colors of the current and other dots |