Mobile

Mobile App Bar

A phone screen header whose large in-flow title scrolls away and crossfades into a 44px bar, with the bar's surface bleeding under the safe-area top inset.

Preview in your theme

Loading preview…

"use client"

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

/**
 * Compact row height in px. 44 is the platform touch-target floor, so the row is
 * exactly one target tall and the icon buttons can fill it edge to edge.
 */
const BAR_HEIGHT = 44
/** Fraction of the collapse travel over which the large title fades out. */
const LARGE_FADE_END = 0.6
/** Window over which the compact title fades in — it overlaps the large fade, which is what makes it read as a crossfade rather than a gap. */

Installation

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

The top inset only exists if the document asks to draw under it. Without this the component still works — the bar is simply 44px tall on every device:

<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "MobileAppBar" component — a phone screen
header whose large title lives inside the scroll flow and crossfades into a
compact 44px bar as the content moves (lucide-react for the back chevron; no
animation library, no scroll library).

Contract
- forwardRef<HTMLDivElement> extending
  Omit<React.HTMLAttributes<HTMLDivElement>, "title" | "onScroll">. onScroll is
  omitted deliberately: the scroller is internal, and scroll does not bubble, so
  a consumer handler on the root would silently never fire.
- title: string (required). Rendered twice — large in the flow, compact in the bar.
- subtitle?: string — a second line under the large title. variant="compact"
  has no large title row and ignores it.
- variant?: "center" | "pill" | "compact" (default "center").
- onBack?: () => void, backLabel?: string (default "Back"). No onBack, no button.
- actions?: { key, label, icon: ReactNode, onSelect?, href?, disabled? }[] —
  trailing icon controls.
- scrollLabel?: string (default "Page content") — the accessible name of the
  scroll region, i.e. of the thing a keyboard user tabs to.
- onCollapsedChange?: (collapsed: boolean) => void — notification only. Scroll
  position owns this state, so there is no `collapsed` prop to feed back in;
  the write side is scrollAreaRef.
- scrollAreaRef?: React.RefObject<HTMLDivElement | null> — hands the internal
  scroller back for restoring a position or scrolling to top after navigation.
- children: the screen content, rendered inside that scroller.

Behavior
- Layout: the root is relative + overflow-hidden + a flex column with a
  caller-set height (h-dvh in a real app). Two children: the bar, absolutely
  positioned at the top, and the scroll region filling the rest. The large title
  is the FIRST thing inside the scroller, under a spacer of
  calc(env(safe-area-inset-top) + 44px) — so it scrolls away 1:1 with the
  content and is exactly hidden behind the bar once it has travelled its own
  height. That is the whole geometry; nothing is animated on a timeline.
- The collapse distance is MEASURED, never declared: a ResizeObserver on the
  large title block writes its offsetHeight into a ref (disconnected on unmount,
  skipped when ResizeObserver is undefined). A title that wraps to two lines, or
  one that gains a subtitle, simply takes longer to collapse, and no consumer
  ever states a height.
- progress = clamp(scrollTop / measuredHeight, 0, 1), never a bare division —
  two explicit branches guard it. No large title (variant="compact") pins
  progress at 1; a large title that has not been measured yet (height 0) pins it
  at 0, so it stays EXPANDED rather than hiding a title that is still on screen
  behind a solid bar.
- Crossfade: large title opacity = clamp(1 - progress / 0.6); the compact title
  ramps over progress 0.45 -> 0.95 and rises 8px as it arrives. The two windows
  OVERLAP — that overlap is what makes it read as one title moving between two
  sizes instead of two titles taking turns.
- The bar's glass surface and its hairline are separate absolutely positioned
  layers, each with opacity = progress. Never fade the row itself: that would
  fade the buttons out with it.
- Painting: the scroll listener is passive and coalesced into one
  requestAnimationFrame; opacity and transform are written straight to the nodes
  through refs, so a fling never re-renders the screen. The only thing React
  hears about is one boolean.
- That boolean has hysteresis — it enters collapsed at 0.6 and leaves at 0.5 —
  so a scroll parked on the boundary cannot emit a stream of callbacks. Mirror
  it in a ref that is read AND written inside the handler, and expose it as
  data-state="collapsed" | "expanded" (plus data-variant) for consumer styling.
- No pointer handlers, no preventDefault, no captured gesture: the browser's own
  scrolling drives everything, which is exactly what keeps momentum, overscroll
  bounce, find-in-page and screen-reader scrolling working. The scroll region is
  role="region" + aria-label + tabIndex=0, so Tab lands on it and arrows /
  PageDown / End collapse the bar while Home expands it — the gesture-free path
  to the same state.
- Actions: an item with href renders a real <a>, otherwise a type="button".
  `disabled` is reported with aria-disabled and enforced by an early return in
  the click handler (which also preventDefaults the anchor) — never the native
  attribute, which blurs the control the instant it is set and drops focus onto
  <body> for whoever was standing on it.
- Optical centring without measuring (variant="center"): pad the title box by
  the width difference between the two control clusters (back button 44px vs
  actions x 44px), so the centred title sits on the screen's midline rather than
  on the midline of whatever space is left over.
- Accessibility: the large title is the <h1>; the compact echo is aria-hidden or
  every screen announces its name twice. In variant="compact" there is no large
  title, so the bar's title IS the <h1>. The bar itself is a <header> labelled
  with the title; decorative icons are aria-hidden and the controls carry their
  own aria-label.
- Cleanup: disconnect the ResizeObserver, remove the scroll listener, cancel both
  rAF handles on unmount and whenever the paint callback changes. The
  reduced-motion media query is subscribed via useSyncExternalStore, not read
  once, and unsubscribes with the component.

Rendering & styling
- Semantic tokens only: bg-background for the screen, bg-background/80 +
  backdrop-blur for the bar surface, bg-border for the hairline, text-foreground
  and text-muted-foreground for type, hover:bg-muted for the icon buttons,
  focus-visible:ring-2 ring-ring throughout. The pill INVERTS — bg-foreground
  text-background — instead of taking a colour.
- Safe area: the bar pads with padding-top:
  var(--safe-area-inset-top, env(safe-area-inset-top, 0px)) so its glass reaches
  the physical top edge while the controls stay in the 44px row below the inset.
  Padding, never margin — a margin would lift the bar and expose the page above
  it. The var() indirection lets a device-frame preview or a screenshot test
  simulate a notch on a desktop browser. The same expression pads the scroller's
  first child, so the two always line up.
- Touch: the row is 44px, one hit target tall, and every control is size-11
  (44px). touch-action: pan-y on the scroller declares the axis, and
  overscroll-contain stops a phone screen embedded in a page from scrolling its
  parent when it reaches the end.
- Type: large title text-2xl / leading-8 (text-3xl for "pill"), tracking-tight,
  line-clamp-2; compact title text-sm font-semibold with truncate, because the
  row height must never move.
- Reduced motion: the crossfade is scroll-linked information and stays; only the
  8px rise of the compact title is dropped.
- cn() merges the consumer className onto the root; the built-in height is only a
  fallback so the component renders standalone.

Customization levers
- Variant: "center" (centred compact echo + hairline), "pill" (inverted chip on
  the leading edge, no hairline), "compact" (no large title, already collapsed —
  the pushed-detail-screen case). A fourth is one branch in the title box plus
  one in the type scale; measuring and painting are shared.
- Crossfade feel: the 0.6 large-fade end and the 0.45 -> 0.95 compact window are
  the two knobs. Pull them apart for a slow dissolve, overlap them completely for
  a hard swap. The 8px rise is the only decoration in the component.
- Collapse distance: it follows the large title's own box, so tune it with the
  type scale or that block's px-4 pt-2 pb-3 padding, never with a magic number.
- Bar height: one constant feeds the row, the scroll spacer and the centring
  maths at once — 44 is the touch floor. Raising it (56 for a Material-sized bar)
  means moving the controls' size-11 with it, because the centring maths counts a
  control cluster in bar heights.
- Surface: swap bg-background/80 + backdrop-blur for a solid bg-card, or drop the
  hairline and let the blur carry the edge.
- Controls: pass href for real anchors (open-in-new-tab keeps working) or
  onSelect for a handler; disabled marks an action unavailable without pulling it
  out of the tab order.
- Composition: it owns its scroll container, so put a list, a form or a grid
  inside it — but do not nest it with another component that also owns a
  scroller.

Concepts

  • The title is content, not chrome — the large title sits inside the scroll flow and leaves with the list at 1:1, which is why it needs no animation curve: the finger already moves it. Only the fade is computed. A desktop sticky header shrinks a box that never moves; this one gets out of the way and leaves a 44px echo behind.
  • Measured collapse distance — the travel is the title block's own offsetHeight, read from a ResizeObserver. Wrap the title to two lines or add a subtitle and the bar simply collapses over a longer stretch; nothing is declared, so nothing can be declared wrong.
  • Overlapping crossfade windows — the large title is gone by 60% of the travel and the compact one starts at 45%. The overlap is deliberate: a gap between the two windows reads as a title that vanished, the overlap reads as a title that moved.
  • Paint by ref, report by state — every continuous value (three opacities and one transform) is written directly onto the DOM nodes inside a single rAF, so a fling costs no renders. Exactly one boolean crosses into React, with hysteresis at 0.6 / 0.5 so a scroll resting on the boundary cannot chatter.
  • Safe-area bleedpadding-top: env(safe-area-inset-top) on the bar means the glass reaches the physical edge and the status bar sits on top of it, while the controls stay in the reachable row beneath. Margin would have lifted the bar and shown the page through the gap.
  • The scroll region is the keyboard path — the collapse has no gesture handler to duplicate, so accessibility is a matter of making the scroller itself reachable: role="region", an aria-label and tabIndex=0. Tab in, then Page Down collapses the bar and Home expands it; scrollAreaRef gives a button the same power.

On This Page