A focus timer that lives in the macOS menu bar: a status item where the countdown rolls, a popover with the session, its length, Do Not Disturb and today's sessions, and a HUD that confirms start, pause and the finish. Built on Mac Menu Popover, Mac HUD, Number Roll, Text Swap, Button, Segmented and Switch.
When to use
- A menu bar utility built around one running thing: a focus or break timer, a stopwatch, a time tracker.
- A Mac app whose state should stay visible while people work elsewhere: the countdown sits in the menu bar, and the popover holds everything else.
- A web page for your Mac app, showing it working on a desktop as it does on a Mac.
When not to use
- A menu bar panel with no timer in it: use Mac Menu Popover with your own rows.
- A countdown inside a web page, such as an offer ending: use Number Roll on its own.
Installation
npx shadcn add @eased-pro/mac-focus-timerNeeds the @eased-pro registry and your Pro token in components.json. Create a token and copy the setup from your account .
Usage
Render it where your menu bar's status items go. It brings its own status item, popover and HUD.
import { MacFocusTimer, type FocusSession } from "@/components/eased/mac-focus-timer/mac-focus-timer";export function MenuBar({ today, save }: { today: FocusSession[]; save: (s: FocusSession) => void }) { return ( <MacFocusTimer name="Stint" defaultSessions={today} onSessionEnd={save} onDoNotDisturbChange={(on) => setDoNotDisturb(on)} onOpenSettings={() => openSettings()} onQuit={() => quit()} /> );}The Do Not Disturb switch is a preference: onDoNotDisturbChange tells you when to turn the system's Do Not Disturb on and off, at the start and end of each session. Start and Resume close the popover, so the countdown carries on in the menu bar; Pause leaves it open. End stops the session early and keeps it as a session if it ran for a minute or more. A new length mid-session keeps the time already focused, and lengths shorter than that are disabled.
Examples
On a desktop
Keep the popover and HUD on one screen: pass the same element as the popover's collision boundary and the HUD's container. The HUD follows the element's box and shows above the open popover.
const [screen, setScreen] = useState<HTMLDivElement | null>(null);<div ref={setScreen} className="h-[560px]"> <MenuBar> <MacFocusTimer collisionBoundary={screen ?? undefined} hudContainer={screen} /> </MenuBar></div>Restoring a session
After a relaunch, mount it running with the time that was left.
<MacFocusTimer defaultLength={50} defaultRemaining={saved.remaining} />API reference
MacFocusTimer
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | "Focus" | The app's name: the panel's title, the status item's name and the Quit row. |
lengths | number[] | [15, 25, 50] | Session lengths on offer, in minutes. |
defaultLength | number | 25 | The selected length, in minutes. |
defaultRemaining | number | Mount with a session running and this many ms left. | |
defaultSessions | FocusSession[] | [] | Today's sessions, newest first. The panel lists the last three and totals them all. |
onSessionEnd | (session: FocusSession) => void | A session ran out, or was ended after a minute or more. | |
defaultDoNotDisturb | boolean | false | The switch: Do Not Disturb turns on for each session while it is on. |
onDoNotDisturbChange | (on: boolean) => void | When the system's Do Not Disturb should change: true as a session starts with the switch on (or it goes on mid-session), false when the session ends or the switch goes off. Pausing keeps it on. Toggling the switch between sessions calls nothing. Mounting with defaultDoNotDisturb and defaultRemaining calls it with true on mount; unmounting mid-session does not call it with false, so turn it off yourself on quit. | |
onOpenSettings / onQuit | () => void | Add the Settings… (⌘,) and Quit (⌘Q) rows. Display only: wire the keys yourself. | |
now | () => number | Date.now | The clock, epoch ms. Pass a stable function. |
locale / timeZone | string | the runtime's | For the sessions' end times. |
material | "css" | "native" | "css" | native drops the CSS blur on the panel and HUD for a window with real vibrancy. |
collisionBoundary | Element | Element[] | the viewport | The area the popover stays inside. |
hudContainer | HTMLElement | null | the viewport | An element the HUD sits over, centred near its bottom and above the open popover. null waits for the element. |
FocusSession
| Field | Type | Description |
|---|---|---|
id | string | |
length | number | What it was set to, in minutes. |
focused | number | Time focused, ms: the whole length when it ran out, less when it was ended early. |
endedAt | number | Epoch ms. |
Keyboard
| Key | Action |
|---|---|
Enter / Space | On the status item, opens the popover with focus on its first control. |
Tab | Moves through End, Start or Pause, Length, Do Not Disturb and the app's rows. |
← / → | Change the length. |
Escape | Closes the popover and returns focus to the status item. |
Accessibility
- The status item is named for what it shows: "Stint, 12 minutes left", "Stint, paused, 12 minutes left", "Stint, session done". It names minutes, not seconds, so it doesn't change every second.
- Start, pause, resume and the finish are announced from the HUD's live region ("Paused, 12 minutes left", "Session done, 25 minutes focused"), so a screen reader hears them wherever focus is.
- The time in the panel is a
timernamed in minutes left. Sessions in the list say whether they were completed or ended early, as text as well as the mark: a filled disc with a check, or a partial ring.
Motion
The countdown's digits roll down on the snappy spring in the menu bar and in the panel. The status item's icon is a ring that fills as the session goes, on the smooth spring; pause shows two bars inside it and the time dims. At zero the ring closes on a check, "Done" swaps in for the time while the item's width springs, the HUD shows a check and a full bar, and in the panel the session slides in at the top of Recent while the oldest fades and the day's total rolls. Every change after the first reaches the HUD in place: the glyph cross-fades and un-blurs on the text-swap tokens and the HUD's 1.1-second hold restarts. Buttons press by colour only, as on a Mac. Reduced motion keeps 100ms fades: nothing rolls, slides or springs.
Notes for AI
Pick this for a Mac menu bar timer, or a page that shows one running. Pass today's sessions in defaultSessions and save new ones from onSessionEnd; wire Do Not Disturb, Settings and Quit through their callbacks. On a desktop mock-up, pass the screen as collisionBoundary and hudContainer.
Do not run your own interval or render another countdown, HUD or status item beside it: it keeps the clock, the HUD and the hold timer. Do not wrap the status item in a button or scale it on press.
Related
- Mac Menu Popover: the status item and panel it is built on.
- Mac HUD: the overlay that confirms each change.
- Number Roll: the rolling digits in the countdown.