Navigation
Top app bar
Small, center-aligned, medium and large top app bars that tint on scroll.
Inbox
Photos
Trips
3 upcoming · scroll me
The top app bar shows the screen's title, navigation and key actions. Small and center-aligned bars are 64px tall; medium and large flexible bars give the title more room below the actions. The bar tints to surface-container once content scrolls under it.
Installation
npx @grenishrai/expressive add top-app-barThis copies top-app-bar.tsx into your components folder, along with what it needs from Expressive (utils, styles).
Usage
import { TopAppBar } from "@/components/expressive/top-app-bar";
<TopAppBar
variant="medium"
title="Inbox"
subtitle="3 unread"
navigation={<IconButton aria-label="Menu"><MenuIcon /></IconButton>}
actions={<IconButton aria-label="Search"><SearchIcon /></IconButton>}
/>Props
TopAppBar
| Prop | Type | Default | Description |
|---|---|---|---|
| title | ReactNode | — | Rendered as the page <h1>. |
| subtitle | ReactNode | — | Secondary line. |
| variant | "small" | "center" | "medium" | "large" | "small" | Layout and title size. |
| navigation | ReactNode | — | Leading slot (menu or back button). |
| actions | ReactNode | — | Trailing slot, up to three icon buttons. |
| scrolled | boolean | — | Force the scrolled tint. By default it follows the scroll position. |
| scrollTarget | RefObject<HTMLElement> | window | Scroll container to watch. |
Accessibility
- Renders a
<header>with the title as an<h1>. - Every icon button in the slots needs its own accessible name.
Notes
- Medium and large bars don't collapse their title on scroll yet; they only tint.
Source
This is the file the CLI writes into your project, shown with the default import paths. It's yours to change.
"use client";
import * as React from "react";
import { cn } from "@/lib/expressive/utils";
/*
* Top app bar. `small` and `center` are 64px; `medium` and `large` are the
* M3 Expressive flexible bars with a bigger title (and optional subtitle)
* below the actions row. The container tints to surface-container once the
* page scrolls.
*/
export interface TopAppBarProps extends Omit<React.ComponentProps<"header">, "title"> {
title: React.ReactNode;
subtitle?: React.ReactNode;
variant?: "small" | "center" | "medium" | "large";
/** Leading slot, usually a menu or back IconButton. */
navigation?: React.ReactNode;
/** Trailing slot for up to three IconButtons. */
actions?: React.ReactNode;
/** Force the scrolled look. By default it follows `scrollTarget` (or the window). */
scrolled?: boolean;
scrollTarget?: React.RefObject<HTMLElement | null>;
}
function useScrolled(target: TopAppBarProps["scrollTarget"], enabled: boolean) {
const [scrolled, setScrolled] = React.useState(false);
React.useEffect(() => {
if (!enabled) return;
const element = target?.current;
const read = () => setScrolled((element ? element.scrollTop : window.scrollY) > 0);
read();
const source: HTMLElement | Window = element ?? window;
source.addEventListener("scroll", read, { passive: true });
return () => source.removeEventListener("scroll", read);
}, [target, enabled]);
return scrolled;
}
export function TopAppBar({
className,
title,
subtitle,
variant = "small",
navigation,
actions,
scrolled: scrolledProp,
scrollTarget,
...props
}: TopAppBarProps) {
const autoScrolled = useScrolled(scrollTarget, scrolledProp === undefined);
const scrolled = scrolledProp ?? autoScrolled;
const flexible = variant === "medium" || variant === "large";
const heading = (
<div className={cn("flex min-w-0 flex-col", variant === "center" && "items-center text-center")}>
<h1
className={cn(
"truncate text-on-surface",
variant === "medium" ? "text-headline-medium" : variant === "large" ? "text-display-small" : "text-title-large",
)}
>
{title}
</h1>
{subtitle && (
<p className={cn("truncate text-on-surface-variant", flexible ? "text-title-medium" : "text-label-medium")}>{subtitle}</p>
)}
</div>
);
return (
<header
data-slot="top-app-bar"
data-scrolled={scrolled ? "" : undefined}
className={cn(
"flex w-full flex-col bg-surface px-1 transition-colors spring-effects-default data-scrolled:bg-surface-container",
className,
)}
{...props}
>
<div className="flex h-16 items-center gap-1">
<div className="flex w-12 shrink-0 justify-center empty:w-3">{navigation}</div>
<div className={cn("min-w-0 flex-1", variant === "center" && "flex justify-center")}>{!flexible && heading}</div>
<div className="flex shrink-0 items-center gap-0 pr-1 empty:w-12">{actions}</div>
</div>
{flexible && (
<div className={cn("flex items-end px-3", variant === "medium" ? "min-h-12 pb-3" : "min-h-14 pb-5")}>{heading}</div>
)}
</header>
);
}