macbetter

Navigation

Dock

Launch and switch between apps from a shelf of icons that grow under the pointer.

macOS only
"use client"

import { Dock, DockItem, DockSeparator } from "macbetter/dock"
import {
  CalendarDaysIcon,
  CompassIcon,
  FolderIcon,
  ImageIcon,
  MailIcon,
  MessageCircleIcon,
  MusicIcon,
  NotebookPenIcon,
  SettingsIcon,
  Trash2Icon,
} from "lucide-react"
import { useState } from "react"

const apps = [
  { name: "Finder", icon: FolderIcon, from: "var(--mb-cyan)", to: "var(--mb-blue)" },
  { name: "Safari", icon: CompassIcon, from: "var(--mb-teal)", to: "var(--mb-blue)" },
  { name: "Messages", icon: MessageCircleIcon, from: "var(--mb-mint)", to: "var(--mb-green)" },
  { name: "Mail", icon: MailIcon, from: "var(--mb-cyan)", to: "var(--mb-indigo)" },
  { name: "Photos", icon: ImageIcon, from: "var(--mb-yellow)", to: "var(--mb-pink)" },
  { name: "Music", icon: MusicIcon, from: "var(--mb-pink)", to: "var(--mb-red)" },
  { name: "Calendar", icon: CalendarDaysIcon, from: "var(--mb-red)", to: "var(--mb-orange)" },
  { name: "Notes", icon: NotebookPenIcon, from: "var(--mb-yellow)", to: "var(--mb-orange)" },
  { name: "System Settings", icon: SettingsIcon, from: "var(--mb-tint)", to: "color-mix(in oklch, var(--mb-tint), black 45%)" },
]

function AppIcon({ icon: Icon, from, to }: { icon: typeof FolderIcon; from: string; to: string }) {
  return (
    <span
      style={{
        display: "grid",
        placeItems: "center",
        background: `linear-gradient(160deg, ${from}, ${to})`,
      }}
    >
      <Icon aria-hidden="true" strokeWidth={1.75} style={{ width: "54%", height: "54%" }} />
    </span>
  )
}

export default function Example() {
  const [running, setRunning] = useState(["Finder", "Safari", "Messages"])

  return (
    <div style={{ display: "flex", alignItems: "flex-end", height: 200 }}>
      <Dock aria-label="Dock">
        {apps.map((app) => (
          <DockItem
            key={app.name}
            label={app.name}
            running={running.includes(app.name)}
            onClick={() => setRunning((open) => (open.includes(app.name) ? open : [...open, app.name]))}
          >
            <AppIcon {...app} />
          </DockItem>
        ))}
        <DockSeparator />
        <DockItem label="Trash">
          <AppIcon icon={Trash2Icon} from="color-mix(in oklch, var(--mb-tint) 45%, white)" to="var(--mb-tint)" />
        </DockItem>
      </Dock>
    </div>
  )
}

When to use it

Use a dock as the launcher of a desktop-like experience: a portfolio, a product tour, or a web app that hosts several tools. Each item is an app or a place people come back to often.

Keep it to the destinations people use most, in an order that doesn't change on its own. Mark the ones that are open with the running dot, and put documents and the Trash after a separator, as macOS does. The dock is macOS chrome; on iPhone and iPad, use a tab bar.

Usage

import { Dock, DockItem, DockSeparator } from "macbetter/dock"

Items, labels and the running dot

Give every DockItem a label: it's the item's accessible name and the label that appears above it on hover. Put the icon inside as its only child; the dock rounds it to the app-icon shape and sizes it. running draws the dot under an open app.

macOS only
"use client"

import { Dock, DockItem, DockSeparator } from "macbetter/dock"
import {
  CalendarDaysIcon,
  CompassIcon,
  FolderIcon,
  ImageIcon,
  MailIcon,
  MessageCircleIcon,
  MusicIcon,
  NotebookPenIcon,
  SettingsIcon,
  Trash2Icon,
} from "lucide-react"
import { useState } from "react"

const apps = [
  { name: "Finder", icon: FolderIcon, from: "var(--mb-cyan)", to: "var(--mb-blue)" },
  { name: "Safari", icon: CompassIcon, from: "var(--mb-teal)", to: "var(--mb-blue)" },
  { name: "Messages", icon: MessageCircleIcon, from: "var(--mb-mint)", to: "var(--mb-green)" },
  { name: "Mail", icon: MailIcon, from: "var(--mb-cyan)", to: "var(--mb-indigo)" },
  { name: "Photos", icon: ImageIcon, from: "var(--mb-yellow)", to: "var(--mb-pink)" },
  { name: "Music", icon: MusicIcon, from: "var(--mb-pink)", to: "var(--mb-red)" },
  { name: "Calendar", icon: CalendarDaysIcon, from: "var(--mb-red)", to: "var(--mb-orange)" },
  { name: "Notes", icon: NotebookPenIcon, from: "var(--mb-yellow)", to: "var(--mb-orange)" },
  { name: "System Settings", icon: SettingsIcon, from: "var(--mb-tint)", to: "color-mix(in oklch, var(--mb-tint), black 45%)" },
]

function AppIcon({ icon: Icon, from, to }: { icon: typeof FolderIcon; from: string; to: string }) {
  return (
    <span
      style={{
        display: "grid",
        placeItems: "center",
        background: `linear-gradient(160deg, ${from}, ${to})`,
      }}
    >
      <Icon aria-hidden="true" strokeWidth={1.75} style={{ width: "54%", height: "54%" }} />
    </span>
  )
}

export default function Example() {
  const [running, setRunning] = useState(["Finder", "Safari", "Messages"])

  return (
    <div style={{ display: "flex", alignItems: "flex-end", height: 200 }}>
      <Dock aria-label="Dock">
        {apps.map((app) => (
          <DockItem
            key={app.name}
            label={app.name}
            running={running.includes(app.name)}
            onClick={() => setRunning((open) => (open.includes(app.name) ? open : [...open, app.name]))}
          >
            <AppIcon {...app} />
          </DockItem>
        ))}
        <DockSeparator />
        <DockItem label="Trash">
          <AppIcon icon={Trash2Icon} from="color-mix(in oklch, var(--mb-tint) 45%, white)" to="var(--mb-tint)" />
        </DockItem>
      </Dock>
    </div>
  )
}

Magnification and launching

Move the pointer along the dock: icons grow with their distance from the pointer and spring back when it leaves. Set magnification to change how big the icon under the pointer gets, or 1 to turn it off. Clicking an item that isn't running bounces it while it launches. Pass href to make an item a link.

Platform behavior

  • macOS: the dock is a glass shelf. Magnification follows a mouse or trackpad pointer only, never touch, and is off under Reduce Motion.
  • iPadOS and iOS: there's no magnification without a pointer; items keep their size and the shelf works like a toolbar of icons.
  • Icons default to 48px; set --mb-dock-icon-size on the dock to change it.
  • The shelf keeps a little more room at its ends than at its top, so the first and last icons don't crowd its curved corners.
  • The shelf and the icon tiles have Apple's continuous corners where the browser supports corner-shape, and plain rounded corners elsewhere. See Squircle.

Accessibility

  • The dock is a ui-sdk Toolbar: one tab stop, then Left and Right move between items and Home and End go to the ends. Name it with aria-label.
  • Each item is a button, or a link with href, named by its label. The visible label is hidden from assistive technology so the name isn't read twice.
  • Keyboard focus shows the accent ring and the item's label.

API

Dock

PropTypeDefaultDescription
magnificationnumber1.75How large the icon under the pointer grows. 1 turns it off.
loopboolean—Whether arrow keys wrap from the last item to the first.
aria-labelstring—Names the dock.

DockItem

PropTypeDefaultDescription
labelstring—The app's name, read by assistive technology and shown above the icon.
runningboolean—Draws the dot under an open app. Items that aren't running bounce when clicked.
hrefstring—Renders the item as a link.
onClick(event) => void—Runs when the item is pressed.
disabledboolean—Keeps the item focusable but inert.

DockSeparator

The line between apps and documents. Takes no props of its own.

Data attributes and CSS variables

Attribute or variableOnMeaning
data-magnifying.mb-dockThe pointer is over the dock
data-running.mb-dock-itemThe app is open
data-bouncing.mb-dock-itemThe app is launching
--mb-dock-icon-size.mb-dockResting icon size, 48px
--mb-dock-padding.mb-dockSpace above the icons, 5px
--mb-dock-padding-inline.mb-dockSpace before the first icon and after the last, 9px
--mb-dock-scale.mb-dock-itemThe icon's current magnification, written as the pointer moves