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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/mobile-app-bar.jsonThe 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 aResizeObserver. 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 bleed —
padding-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", anaria-labelandtabIndex=0. Tab in, then Page Down collapses the bar and Home expands it;scrollAreaRefgives a button the same power.
Translation Panel
A document beside its machine translation, aligned segment by segment — hover or focus a pair and both halves light up, with per-segment translated / waiting / failed states, a swappable language header, a formality control and progress over the whole job.
Dynamic Island
A notch-anchored live-activity pill that morphs between idle, compact, minimal and expanded presentations.