The page header row: an identity block (icon or avatar), a title and subtitle, an inline zone right next to the title, and a right-aligned actions bar, with an optional back-link row above all of it.
The canon behind it. docs/design-language/09-page-composition.md §A4 (09-page-composition.md:25, PageTitle's slot in the fixed composition order), docs/design-language/08-navigation-and-shell.md §A2 (08-navigation-and-shell.md:17, the one map a page's icon and label read from), and docs/design-language/04-loading-and-states.md §A3 (04-loading-and-states.md:21, never blank a title already known).
Entrance. The title row fades up as one; the icon disc arrives a beat later (animate-discIn, 0.14s delay, scale from 0.7), owner ruling doc 59 S3. noAnimate stills both.
Phone width. The title block keeps a 14rem floor (min-w-[min(100%,14rem)]), so a wide actions bar wraps under the title on a phone rather than squeezing the subtitle into a one-word column (doc 59 S6 family, found by the wave 2b categorical pass).
Toolbar actions. The right-aligned actions bar follows the same rule as a card toolbar: no bare outline-plus-neutral button; an arrow for navigation, a shade plus a tone for an action; icon-only actions carry the rich two-line tooltip (canon doc 2 §A10, doc 60 §6, tooltip.md "The rich preset").
When to reach for it#
PageTitle is a fixed step in the page composition order, not an optional header. Canon doc 9 §A4 states the order inside a page's main column: breadcrumbs (2+ levels deep only) → route outlet → panel-inner[.wide] → PageTitle (pinned shrink-0 inside scrolling flex columns) → stat tiles → cards, sections, tables. "Pages differ in content, not in ordering." Doc 8 §B lists it as one of the shell's own presets, alongside the sidebar and breadcrumbs, because the sidebar, the page title, and any tooltip naming a page all read the same PAGE_META entry (doc 8 §A2), "so a page's icon/label can never drift from its sidebar entry."
Every page in both apps that isn't a bare auth screen opens with a PageTitle. It is the one place a page states what it is before rendering anything else.
Contract#
Identity (renders only when Icon or avatar is given, avatar wins):
Icon/iconSize/iconClassName/iconWrapClassName(IconImportProps).avatar: a full JSX node (a gravatar, a glyph) that replaces the icon inside the same disc. Real usage renders a module glyph or an icon component directly as the avatar (speedway/app/routes/workspaces/modules.tsx:684, 786).
Title row:
title(required) andsubtitle: string or JSX viarenderNode. A string gets the row's own styling (text-2xl font-boldfor the title,text-base-content/65 text-smfor the subtitle,page-title.tsx:78, 81). JSX passes through unstyled, so a JSX title must dress itself:speedway/app/routes/workspaces/jobs/job.tsx:256-272wraps anInlineEditin its own<h1 className="truncate text-2xl font-bold">, matchingpage-title.tsx:78's class verbatim, with the reasoning stated inline: "PageTitle styles STRING titles only; a JSX title must dress itself."titleExtra: an inline zone right beside the title, for a status pill or a small toggle that belongs to the title itself rather than the actions bar.walmart-mvp/frontend/src/pages/Jobs.tsx:833-852puts a clickableStatusPillcount here ("in review"), toggled as a filter.actions: the right-aligned actions bar. The sameJobs.tsxblock pairs a last-fetched timestamp, a Refresh button, and a primary "New job" button in oneactionsnode (:853-878).
Back row:
back: its own row above the title. Pass the app's own<Link>; the kit adds the left arrow and the muted hover styling (page-title.tsx:55-65), so a caller only supplies the link text and destination, never the arrow markup.
State:
noAnimate: opts out of theanimate-fadeInUpentrance the row plays by default on mount (skeleton mode never animates). Every page opens with onePageTitle, so the row's own entrance is the app's de facto "page ready" cue; this is why no playground page needs a dedicated animation demo, every page header already shows it.skeleton: a slot-aware placeholder. It mirrors the real row's structure and draws bars only for the slots the page actually uses (an identity disc bar only ifIcon/avatarwould render, an actions bar only ifactionswould render), perpage-title.tsx:27-50. No shipped usage ofskeletonfound in either app. Every skeleton and boot-loading page found instead rendersPageTitlewith its real, usually static, title and subtitle:walmart-mvp/frontend/src/lib/routeSkeletons.tsx:42-46, 87-91, 101-105renders the exact same icon/title/subtitle a route's own component would, inside what is otherwise a placeholder page. This matches canon doc 4 §A3 directly: "Static, known-ahead-of-time text renders immediately... the title is derived from the URL when possible: name it instead of an anonymous bar." Reach forskeletononly when the title itself is genuinely unknown at render time, not merely because the rest of the page hasn't loaded.
Slots and DOM:
- Slot classnames per
PageTitleClassNames:className,iconWrapClassName,titleClassName,subtitleClassName,actionsClassName,backClassName.
Sanctioned combinations#
| Combination | Produces | Where used | Why |
|---|---|---|---|
Icon + title + subtitle + titleExtra + actions | A list page's full header: identity, copy, an inline status toggle, and a trailing action cluster | walmart-mvp/frontend/src/pages/Jobs.tsx:829-879 | The richest real header in either app; every slot but back and avatar in one call |
title as a renderNode-styled string, back as a real <Link>, actions as a button cluster | A detail page's header with a return path | speedway/app/routes/workspaces/jobs/run.tsx:163-166, speedway/app/routes/admin/team.tsx:288-297 | The detail-page-with-back preset from canon doc 9 §B |
title as JSX (InlineEdit wrapped in an <h1> carrying the row's own title classes) | A title the user can rename in place, styled identically to a plain string title | speedway/app/routes/workspaces/jobs/job.tsx:256-272 | Demonstrates the "JSX must dress itself" rule with the exact class string page-title.tsx:78 uses |
back={<span />} with no other back content, alongside a manually-skeletoned title beneath it | Reserves the back row's height while the real back link's destination is still loading | speedway/app/routes/account/accounts.tsx:76-82 | The page's PageTitle itself is NOT in skeleton mode here; only a sibling <div className="skeleton ..."> bones the title text, while back holds an empty node so the layout doesn't jump once the real link resolves |
avatar={<Glyph size={18} />} | A module's own glyph in the identity disc instead of a stock icon | speedway/app/routes/workspaces/modules.tsx:684, 786 | avatar wins over Icon for module-identity pages where the glyph IS the identity |
Banned combinations#
Do not hand-build the back-link arrow or its hover styling. back is a slot for the link's content only; page-title.tsx:62 supplies the LeftArrow icon and the muted-to-solid hover transition itself. A caller that renders its own arrow before the link duplicates kit chrome.
Do not reach for skeleton to blank a title that is already known. As shown above, real boot-skeleton pages render PageTitle with the actual static title and subtitle text, never the slot-aware placeholder, because the page's identity is fixed at build time even when its data is not (canon doc 4 §A3).
Do not style a JSX title expecting renderNode to do it. Only strings, numbers, and booleans get wrapped and styled (internal/render-node.tsx:16-19); a JSX node passes through untouched, so its className is the caller's responsibility, per the job.tsx case above.
Before you adopt this#
Five questions to answer before reaching for PageTitle.
- Does the shell, a parent layout, or a global provider already render this? Not applicable, PageTitle is a fixed step in the page's own composition order.
- Does this app already ship a local implementation of the same thing? Not applicable, no local PageTitle clone is documented in either app.
- Does this app's kit pin reach the version this component or prop landed in? Not applicable, no version-landed PageTitle prop is noted in this doc.
- Does the component derive its own accessible name and keyboard path, or must the call site supply them?
backsupplies only link content; the kit adds the arrow and hover styling itself. - Which canon §A rules bind this surface, and which does the composition break? §A2 reads the same
PAGE_METAentry as the sidebar; icon and label can't drift.
Travels with#
PAGE_META, the app-level single source for a page's icon and label (canon doc 8 §A2). PageTitle's Icon and title are meant to read from the same map the sidebar and breadcrumbs use, not a separately hand-typed icon/string pair.
Card and StatTile, which follow PageTitle in the fixed composition order (canon doc 9 §A4): PageTitle is pinned shrink-0 above them on scrolling pages.
The per-route boot-skeleton module (routeSkeletons.tsx), which renders the real PageTitle inside an otherwise-placeholder page (canon doc 4 §A10).
Snippet#
// walmart-mvp/frontend/src/pages/Jobs.tsx:829-879<PageTitle Icon={appIcon.jobs} title="Jobs" subtitle="Every batch you've imported and exactly where it is in the pipeline. Click a row for the stage-by-stage view." titleExtra={ jobs && jobs.some((j) => j.status === "in_review") ? ( <button type="button" onClick={() => setReviewOnly((v) => !v)} aria-pressed={reviewOnly} aria-label="Show only jobs in review" className={`cursor-pointer rounded-full transition-shadow ${reviewOnly ? "ring-primary/35 ring-2 ring-offset-1" : ""}`} > <StatusPill kind="review" size="sm" count={jobs.filter((j) => j.status === "in_review").length}> in review </StatusPill> </button> ) : undefined } actions={ <div className="flex items-center gap-3"> <span className="text-base-content/50 text-xs whitespace-nowrap"> {lastFetchedAt ? `Updated ${lastFetchedAt.toLocaleTimeString([], { hour: "numeric", minute: "2-digit", second: "2-digit" })}` : null} </span> <Button variant="ghost" shade size="sm" Icon="Refresh" content="Refresh" loading={jobsSWR.revalidating} onClick={refreshNow} /> <Button color="success" size="sm" Icon="Plus" content="New job" onClick={() => navigate("/import")} /> </div> }/>