<!-- Generated from src/components/patterns/page-header.mdx by scripts/build-docs.ts. Do not edit. -->

# Page header design contract

PageHeader owns the title and action area of an Admin screen. Its action controls
share the same appearance, spacing, tooltip behavior and primary-action treatment.
Use the same action components in analytics headers whose navigation and metadata
require a different overall layout.

The blurred background in PageHeader and ListPage.Header uses the page background
token in both themes, so an unscrolled header blends into the canvas. Its translucent
gradient and backdrop blur soften content scrolling beneath it.

## Slots

PageHeader is composed from named slots rather than configured with props:

- `PageHeader.Left`: the title block.
- `PageHeader.Breadcrumb`: a small, muted breadcrumb above the title.
- `PageHeader.Title`: the page's `h1`; it can hold an inline `PageHeader.Count`.
- `PageHeader.Count`: a secondary count beside the title.
- `PageHeader.Description`: a paragraph below the title.
- `PageHeader.Meta`: a small, muted metadata line below the title.
- `PageHeader.Actions`: the action area at the right.
- `PageHeader.ActionGroup`: groups the actions, with `.Primary` and `.MobileMenu` slots.

PageHeader owns presentation. Permissions, routing, queries and keyboard-shortcut
bindings stay in the feature that renders it.

## Order and hierarchy

Place actions in reading and keyboard order:

1. **Icon-only secondary actions:** ordered by importance or frequency, with the
   overflow menu (•••) last when present.
2. **Labelled secondary actions:** icon + label, ordered by importance or frequency.
3. **A gap** separating secondary actions from the primary.
4. **An optional single primary action:** icon + label, at the right edge.

For example: Search → More actions → Filter → Date range → gap → New member. Importance
is a product decision within each group. The component preserves the supplied DOM
order; it does not infer importance or sort children. A page without a meaningful
primary action should omit it.

`PageHeader.ActionGroup` provides **8px** between secondary controls.
`PageHeader.ActionGroup.Primary` provides **20px** separation from the preceding
secondary control. Put at most one primary action in that slot. A primary alone
has no leading separation. Avoid additional margins or gaps on individual actions.

_Story: Patterns / Page Header → Structure_

```tsx
import React from 'react';
import { Ellipsis, Plus, Search } from 'lucide-react';
import { PageHeader, type Filter, Filters, FilterBar } from '@tryghost/shade/patterns';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, InputGroup, InputGroupAddon, InputGroupInput } from '@tryghost/shade/components';
import { Stack } from '@tryghost/shade/primitives';
import { useShade } from '@tryghost/shade/app';
import { formatNumber } from '@tryghost/shade/utils';

function MoreActions() {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <PageHeader.Action label="More actions" iconOnly>
          <Ellipsis />
        </PageHeader.Action>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem>Import members</DropdownMenuItem>
        <DropdownMenuItem>Export members</DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

function SearchAction({ initialQuery = '' }: { initialQuery?: string }) {
  const { controlShape } = useShade();
  const [query, setQuery] = React.useState(initialQuery);
  const [expanded, setExpanded] = React.useState(!!initialQuery);
  const restoreTriggerFocus = React.useRef(false);
  if (!expanded && !query) {
    return (
      <PageHeader.Action
        ref={(button) => {
          if (button && restoreTriggerFocus.current) {
            button.focus();
            restoreTriggerFocus.current = false;
          }
        }}
        label="Search members"
        iconOnly
        onClick={() => setExpanded(true)}
      >
        <Search />
      </PageHeader.Action>
    );
  }
  return (
    <InputGroup className="w-44" shape={controlShape} variant="secondary">
      <InputGroupAddon>
        <Search />
      </InputGroupAddon>
      <InputGroupInput
        aria-label="Search members"
        placeholder="Search members..."
        value={query}
        autoFocus
        onBlur={() => {
          if (!query) {
            setExpanded(false);
          }
        }}
        onChange={(event) => setQuery(event.target.value)}
        onKeyDown={(event) => {
          if (event.key === 'Escape' && !query) {
            event.preventDefault();
            restoreTriggerFocus.current = true;
            setExpanded(false);
          }
        }}
      />
    </InputGroup>
  );
}

function MembersHeader({
  filtered = false,
  expandedSearch = false,
  mobile = false,
}: {
  filtered?: boolean;
  expandedSearch?: boolean;
  mobile?: boolean;
}) {
  const [filters, setFilters] = React.useState<Filter[]>(
    filtered ? [{ id: 'status', field: 'status', operator: 'is', values: ['paid'] }] : [],
  );
  const filterControls = (
    <Filters
      addButton={<Filters.Trigger />}
      fields={[
        {
          key: 'status',
          label: 'Status',
          type: 'select',
          options: [
            { value: 'paid', label: 'Paid' },
            { value: 'free', label: 'Free' },
          ],
        },
      ]}
      filters={filters}
      keyboardShortcut="f"
      onChange={setFilters}
    />
  );
  return (
    <Stack className="p-6" gap="md">
      <PageHeader blurredBackground={false} sticky={false}>
        <PageHeader.Left>
          <PageHeader.Breadcrumb>Audience</PageHeader.Breadcrumb>
          <PageHeader.Title>
            Members<PageHeader.Count>{formatNumber(12345)}</PageHeader.Count>
            <PageHeader.Description>Readers and subscribers</PageHeader.Description>
          </PageHeader.Title>
        </PageHeader.Left>
        <PageHeader.Actions>
          <PageHeader.ActionGroup mobileMenuBreakpoint={mobile ? 10000 : 640}>
            <SearchAction initialQuery={expandedSearch ? 'Jamie' : ''} />
            <MoreActions />
            {filters.length === 0 && filterControls}
            {mobile && (
              <PageHeader.ActionGroup.MobileMenu>
                <PageHeader.ActionGroup.MobileMenuTrigger>
                  <PageHeader.Action label="More actions" iconOnly>
                    <Ellipsis />
                  </PageHeader.Action>
                </PageHeader.ActionGroup.MobileMenuTrigger>
                <PageHeader.ActionGroup.MobileMenuContent>
                  <DropdownMenuItem>
                    <Search />
                    Search
                  </DropdownMenuItem>
                  <DropdownMenuItem>Filter members</DropdownMenuItem>
                </PageHeader.ActionGroup.MobileMenuContent>
              </PageHeader.ActionGroup.MobileMenu>
            )}
            <PageHeader.ActionGroup.Primary>
              <PageHeader.Action fallbackVariant="default" label="New member">
                <Plus />
                <span className="hidden sm:inline">New member</span>
              </PageHeader.Action>
            </PageHeader.ActionGroup.Primary>
          </PageHeader.ActionGroup>
        </PageHeader.Actions>
      </PageHeader>
      {filters.length > 0 && <FilterBar>{filterControls}</FilterBar>}
    </Stack>
  );
}

export const Structure = () => <MembersHeader />;
```

## Constructing controls

Use `PageHeader.Action` for buttons. Set `label` to an accessible name that also
explains the action when a tooltip is shown. Set `iconOnly` for utilities; keep labelled
controls as icon + visible text. Put the action in `.ActionGroup.Primary` to inherit
primary styling and suppress its tooltip. `primary` is available when a primary
trigger must be constructed outside that slot, such as a modal trigger.

```tsx
<PageHeader.Actions>
  <PageHeader.ActionGroup>
    <PageHeader.Action label="Search" iconOnly><Search /></PageHeader.Action>
    <PageHeader.Action label="Filter"><ListFilter />Filter</PageHeader.Action>
    <PageHeader.ActionGroup.Primary>
      <PageHeader.Action label="New member"><Plus />New member</PageHeader.Action>
    </PageHeader.ActionGroup.Primary>
  </PageHeader.ActionGroup>
</PageHeader.Actions>
```

The controls inherit the Admin 7 design. They do not need a pill shape, explicit
stroke width, ghost variant, tooltip styling, or page-specific CSS. Secondary
controls use the ghost treatment and **2px icon strokes**. Dropdown text has the
same medium weight as Filter; header dropdown triggers hide their down chevrons.
Header dropdowns use the shared button gap and clear extra right margins on their
icons. Callers should not add icon margins.

Use `PageHeader.SelectTrigger` inside Select for labelled header dropdowns; it
provides the pill shape. Ordinary `SelectTrigger` form fields keep standard rounded
corners. Compose
`PageHeader.Action` with `DropdownMenuTrigger asChild` for action menus. Refs,
accessible state and event handlers reach the underlying control. Dropdown and
popover triggers retain the inset pressed treatment while expanded.

Use `addButton={<Filters.Trigger />}` and `keyboardShortcut="f"` on `Filters`
for the standard page filter control. `Filters.Trigger` reads filter state from
its parent and owns the label, icon, responsive treatment and button chrome. It
uses `PageHeader.FilterTrigger` for the empty Admin 7 header and becomes the
compact Add filter button in an active FilterBar. The header trigger advertises
the shortcut that Filters binds, including the F tooltip. Generic actions can
pass `shortcut` when the screen implements a matching shortcut.

```tsx
<Filters
  addButton={<Filters.Trigger />}
  fields={fields}
  filters={filters}
  keyboardShortcut="f"
  onChange={setFilters}
/>
```

Do not duplicate `addButtonClassName`, icons or Filter/Add filter label conditions
in pages. The shared trigger owns both rollout states. Existing screens can set
`fallbackStyle="funnel"` or `"funnel-plus"` to preserve their previous icons and
labelled add buttons; the default `"list"` preserves the consolidated icon-only
add button. `collapseLabel` preserves the previous icon-only header below `lg`.
These compatibility choices are ignored by the Admin 7 header. A unique previous
surface override can use `fallbackClassName`; repeated rules belong in the shared
trigger. Retain fallback props while legacy surfaces still require them.

## Tooltips and focus

Icon-only actions and actions with keyboard shortcuts have tooltips. Static labelled
buttons omit them: repeating the visible label adds no information. For the same
reason a labelled action with a shortcut shows only its keys, while an icon-only
action names itself before its keys. Set `tooltip`
on a labelled action when its text shows a changing value and the tooltip explains
the action. Primary actions never have tooltips.
`PageHeader.SelectTrigger` also accepts `tooltip={false}` when its visible value
already makes the control clear, such as a date range.

Tooltips use the semantic white surface with a shadow. The first
hover tooltip waits **1 second**. Moving between recently shown tooltips uses a
**300ms skip-delay window**. Keyboard focus shows the tooltip immediately.

`PageHeader.Actions`, standalone `PageHeader.ActionGroup` (including editor and
modal headers), and `NavbarActions` share this timing in Shade. Nested action
groups reuse the header's timing, so moving between groups does not restart the
delay. Pages should not add their own tooltip providers or timing overrides.

Selecting a dropdown item with the pointer closes the menu without reopening the
trigger tooltip merely because focus returns. Keyboard navigation still restores
focus and tooltip access. Switching away from the browser closes open tooltips.
Returning to the browser window also does not reopen a
tooltip on the focused control; hovering or deliberately navigating with the
keyboard still works. Disabled actions remain disabled and retain their size.

_Story: Patterns / Page Header → DisabledActions_

```tsx
import { ArrowUpDown, Plus, Search } from 'lucide-react';
import { PageHeader } from '@tryghost/shade/patterns';

export const DisabledActions = () => (
  <PageHeader.ActionGroup>
    <PageHeader.Action label="Search" disabled iconOnly>
      <Search />
    </PageHeader.Action>
    <PageHeader.Action label="Filter" disabled>
      <ArrowUpDown />
      Filter
    </PageHeader.Action>
    <PageHeader.ActionGroup.Primary>
      <PageHeader.Action label="New member" disabled>
        <Plus />
        New member
      </PageHeader.Action>
    </PageHeader.ActionGroup.Primary>
  </PageHeader.ActionGroup>
);
```

## Search, filtering and responsive behavior

Search starts as an icon button. Activating it expands the input in place and
focuses it. A nonempty query stays visible; empty search can collapse on blur or
Escape. When Escape collapses an input, return focus to the search trigger. Query
state, debounce, URL updates and filtering belong to the feature, not PageHeader.

_Story: Patterns / Page Header → ExpandedSearch_

```tsx
// MembersHeader: see the Structure story above.
export const ExpandedSearch = () => <MembersHeader expandedSearch />;
```

When filters are active, move their chips into the separate FilterBar row so they
can wrap without crowding the title. Keep the remaining header actions in order.

_Story: Patterns / Page Header → FilteredList ("Active filters")_

```tsx
// MembersHeader: see the Structure story above.
export const FilteredList = () => <MembersHeader filtered />;
```

At narrow widths, provide an explicit `.ActionGroup.MobileMenu` when secondaries
need to collapse. Choose the breakpoint from the actual header's available space.
Keep actions reachable and maintain keyboard order. The primary may hide its
visible label at narrow widths, but must retain its accessible name and icon.
Expanded search must not push the primary off-screen.

_Story: Patterns / Page Header → MobileMenu_

```tsx
// MembersHeader: see the Structure story above.
export const MobileMenu = () => <MembersHeader mobile />;
```

## Compatibility and rollout

This section is for Admin's own screens; a Ghost app uses the defaults and can skip it.

React Admin headers use the pill design by default without a Labs flag.
Shade owns visual differences; features retain only historical structural
differences that cannot be expressed through shared controls. Existing consumers may supply `fallbackVariant` and
`fallbackSize` to preserve their prior controls. New consumers should use defaults.

With `useShade().isAdmin7` false, the header retains its previous spacing, chevrons,
control appearance and absence of new header tooltips. Follow the
[Admin 7 milestone convention](https://github.com/TryGhost/Ghost/blob/main/docs/practices/feature-flags.md#admin-7-milestones)
for flag usage and removal; ordinary page code already uses the intended
defaults.

## Review a header

Verify action order, the optional single primary, and spacing at desktop and
mobile widths. Exercise search expansion, active filters, open dropdowns, pointer
selection, keyboard focus and disabled controls in both themes. While compatibility
exists, compare legacy-mode appearance and interactions against the previous Admin,
including menus rendered through portals. Check the current design on every tab that
uses the shared header. For ListPage, its header owns stickiness; use
`sticky={false}` and `blurredBackground={false}` on the nested PageHeader.
