Mobile

Status Bar Mock

A mocked OS status bar — clock, cellular, Wi-Fi, battery — that fills the safe-area band above a phone screen, with notch, Dynamic Island, punch-hole and flat glass.

Preview in your theme

Loading preview…

import * as React from "react"
import { cn } from "@/lib/utils"

/** Which piece of device glass interrupts the band. */
export type StatusBarVariant = "notch" | "island" | "punch" | "flat"

/** How the band sits on whatever is painted under it. */
export type StatusBarSurface = "plain" | "inverted" | "translucent"

/**
 * Apple's marketing time, and the default. Hard-coded on purpose: this is a mock,
 * so it must render the same string on the server, on the client and in a
 * screenshot diff. Nothing in this file reads the wall clock — pass `time` to
 * inject a real instant.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/status-bar-mock.json

Prompt

Build a React + TypeScript + Tailwind "StatusBarMock" component — the OS status bar that fills the
safe-area band above a mobile screen. No runtime dependencies: the four glyphs (signal, Wi-Fi,
battery, charge bolt) are hand-drawn inline SVG in currentColor. Only cn() (clsx + tailwind-merge)
is used, and it can be inlined.

Contract
- export const StatusBarMock = React.forwardRef<HTMLDivElement, StatusBarMockProps>(...), plus a
  default export and a displayName. StatusBarMockProps extends React.HTMLAttributes<HTMLDivElement>;
  every unlisted prop spreads onto the root, className merges through cn(), consumer style wins.
- variant?: "notch" | "island" | "punch" | "flat"      default "notch"   — which glass interrupts the band
- surface?: "plain" | "inverted" | "translucent"       default "plain"
- time?: string | number | Date                        default "9:41"
- locale?: string, timeFormat?: Intl.DateTimeFormatOptions   (Date / epoch branch only)
- clockSide?: "left" | "right"                         default "left"    (iOS left, Android right)
- signal?: number | null (0-4)                         default 4         (null drops the glyph)
- network?: string | null                              default "5G"      (radio label or carrier name)
- wifi?: number | null (0-3)                           default 3         (null drops the glyph)
- battery?: number | null (0-100)                      default 82        (null drops the glyph)
- charging?: boolean, showBatteryPercent?: boolean, lowBatteryAt?: number   default false/false/20
- leading?: ReactNode        extra glyphs on the notification side, beside the clock
- indicators?: ReactNode     extra glyphs just before the battery (alarm, location, DND, lock)
- safeArea?: boolean                                   default true
- onTapToTop?: () => void, tapToTopLabel?: string      default label "Scroll to top"
- No state, no refs, no effects, no timers, no rAF, no listeners, no matchMedia, no observers —
  every value is injected, so there is nothing to clean up and nothing to get out of sync. That is
  also why the file carries no "use client": it stays a server component until a consumer passes
  onTapToTop (which only a client component can do).

Behavior
- Clock. Never read the wall clock at render. The default is the literal "9:41" so the server, the
  client and a screenshot diff always agree. A string prints verbatim. A Date / epoch goes through
  Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit", ...timeFormat }).formatToParts,
  with dayPeriod parts filtered out (device bars show "9:41", never "9:41 AM") and the result
  trimmed. Invalid Date -> the default string; an invalid locale / timeZone throws RangeError, so
  catch it and fall back rather than taking the page down for a decorative bar. Document that both
  locale and timeFormat.timeZone must be set whenever the instant crosses a server render: an
  unset zone makes a UTC server and a local browser disagree on the hour, an unset locale makes an
  en-US server and a browser numbering in another script disagree on the digits, and either way
  React reports a hydration mismatch.
- The band, not a strip under it. minHeight is
  max(var(--safe-area-inset-top, env(safe-area-inset-top, 0px)), 44px), and the row is
  items-center: on a notched device the glyphs centre inside the real inset beside the cutout, on a
  desktop preview the band collapses to the 44px iOS default. Left/right padding is the same
  expression on inset-left / inset-right with a 20px gutter floor, which is what clears the
  landscape side rails. Reading the custom property first means any ancestor can simulate a device
  (--safe-area-inset-top: 59px) with no prop and no device — the same override hook the Safe Area
  component uses. safeArea={false} pins the plain 44px / 20px numbers.
- The cutout is a flex item, never an absolute overlay. It reserves its own width, so a long carrier
  name can never slide under the glass. self-start keeps it flush with the physical top edge inside
  a vertically centred row; island and punch add a top margin so they float below the edge.
  Geometry: notch = h-7 w-2/5 max-w-40 rounded-b-2xl, island = mt-1.5 h-7 w-1/3 max-w-32
  rounded-full, punch = mt-2 size-3.5 rounded-full, flat = no element at all.
- Clusters. Left = clock (when clockSide is "left") + leading. Right = signal bars, network label,
  Wi-Fi arcs, indicators, optional percent, charge bolt, battery, then the clock when clockSide is
  "right". Both clusters are flex-1 min-w-0; every glyph is shrink-0 and only the network label
  truncates. That is the whole overflow story: an over-long carrier string ellipses and the battery
  never leaves the band.
- Levels are clamped, not trusted. Round into 0..max and fall back on NaN / Infinity — a NaN would
  otherwise paint a NaN-wide rect and silently void the whole battery. Bars and arcs above the level
  are dimmed to 0.3 rather than dropped, so the glyph keeps its width at every level. The battery
  fill turns destructive at or below lowBatteryAt unless it is charging; 0 paints nothing, anything
  above 0 keeps a 2px minimum sliver.
- Accessibility. The glyphs are decorative and stay that way: both clusters and the cutout are
  aria-hidden, because announcing a mocked "82%" to a screen reader would be a lie about the user's
  own device. Consumers who need the numbers announced render real text elsewhere. The only node in
  the accessibility tree is the optional tap layer.
- Tap to top. Passing onTapToTop mounts a real <button type="button"> absolutely covering the band —
  the platform affordance a status bar exists for. The band is already >= 44px tall, so it is a full
  touch target with no extra padding; touch-manipulation drops the double-tap-zoom delay. Being a
  button, Enter and Space are the equal keyboard path for free, and focus-visible paints an inset
  ring. No pointer handlers, no thresholds, no capture — a tap is a click. Note in the docs that the
  layer covers the glyph slots, so leading / indicators must stay non-interactive while it is on.
- Reduced motion. There is no motion to reduce: nothing animates, nothing ticks. The only transition
  is the tap layer's press tint, which carries motion-reduce:transition-none.

Rendering & styling
- Semantic tokens only, zero colour literals. plain = text-foreground on the screen's own
  background; inverted = bg-foreground text-background (highest-priority chrome inverts rather than
  taking a colour); translucent = bg-background/75 backdrop-blur for content scrolling under it.
- The cutout is bg-current and every glyph is fill/stroke currentColor, so device glass is always
  whatever the glyphs are: a plain bar gets foreground glass, an inverted bar gets background glass,
  and light + dark both come out right with no per-theme branch. destructive on a low battery fill
  is the only colour in the component, and it is earned.
- Type: clock 13px semibold tracking-tight tabular-nums; network / percent 11px medium; glyph row
  12px tall (signal 17x12, Wi-Fi 16x12, battery 25x12, bolt 10x12 viewBoxes).
- Positioning belongs to the consumer: the root is a plain block. Pass className="absolute inset-x-0
  top-0 z-10" to float it over a scroller, or drop it as the first child of a clipped rounded shell.
- data-variant and data-surface are mirrored onto the root for consumer-side styling hooks.

Customization levers
- Cutout geometry: the whole CUTOUT map is four class strings. Widen the notch (w-2/5 -> w-1/2), lift
  the island (mt-1.5), move the punch hole into a cluster for a top-left camera, or add a device by
  adding a key.
- Band metrics: BAND_PX (44) is the height floor and the touch target — do not drop it below 44 while
  onTapToTop is in use. GUTTER_PX (20) is the side gutter and the landscape floor.
- Which glyphs exist at all: signal / network / wifi / battery each accept null to disappear; leading
  and indicators are open slots for alarm, location arrow, DND, lock, recording dot.
- Platform reading: clockSide plus showBatteryPercent turn the iOS layout into the Android one
  without touching the markup.
- Surfaces: add a "gradient" surface (bg-gradient-to-b from-background/90) or swap
  bg-background/75 backdrop-blur for a solid bg-card if the blur costs too much on low-end devices.
- Density: shrink the type by one step (13px -> 12px, 11px -> 10px) and the gap (gap-1.5 -> gap-1)
  for a compact 360px preview; the band height is independent of both.

Concepts

  • Band fill, not a strip below it — a status bar does not sit under the safe area, it is the safe area. Height is max(inset-top, 44px), so the glyphs centre inside the real inset beside the cutout instead of being pushed below the notch.
  • Glass in currentColor — the cutout is painted with bg-current, so it inherits whatever the glyphs are. One rule covers plain/inverted and light/dark without a colour literal or a per-theme branch.
  • Injected instant — the clock is a prop, defaulting to the literal 9:41. Nothing reads the wall clock at render, so SSR, hydration and screenshot diffs all agree; a real Date needs an explicit locale and timeZone to keep that promise.
  • Decorative chrome — mocked glyphs are aria-hidden on purpose. A screen reader reporting a fake 82% battery would be a lie about the user's own device, so the only accessible node is the optional tap target.
  • Truncating ear — the cutout reserves width in flow and every glyph is shrink-0, leaving the carrier label as the single elastic child. A carrier name longer than the ear ellipses; the battery never gets pushed out of the band.
  • Tap the bar to rewind — the one interaction a status bar really owns. It is a plain button covering the band, which makes the keyboard path (Enter / Space) identical to the tap by construction, with no gesture threshold to mirror.

On This Page