Layout

Page header

The top of a page: title, description, breadcrumb, facts, actions and tabs, reflowing in narrow space.

Built on
<header>Container queries
Client JavaScript
None
Works without React
Yes, as HTML and CSS
Source, gzipped
1.9 kB
Dependencies
React

Invoices

  • 142 this quarter
  • 3 overdue
Try its options

Loading its options.

Code
import { Button } from "@/components/equip/button";
import { PageHeader } from "@/components/equip/page-header";

export default function Demo() {
  return (
    <PageHeader
      level={2}
      title="Invoices"
      meta={[<span key="count">142 this quarter</span>, <span key="due">3 overdue</span>]}
      primaryAction={
        <Button size="s" variant="accent">
          New invoice
        </Button>
      }
      secondaryActions={
        <Button size="s" variant="outline">
          Export
        </Button>
      }
      style={{ width: 208 }}
    />
  );
}
Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<header style="width:208px" class="eq-page-header">
  <div class="eq-page-header-layout">
    <div class="eq-page-header-main">
      <div class="eq-page-header-heading">
        <h2 class="eq-page-header-title">Invoices</h2>
        <ul class="eq-page-header-meta">
          <li class="eq-page-header-meta-item">
            <span>142 this quarter</span>
          </li>
          <li class="eq-page-header-meta-item">
            <span>3 overdue</span>
          </li>
        </ul>
      </div>
      <div class="eq-page-header-actions">
        <button type="button" class="eq-button" data-variant="outline" data-size="s">
          <span class="eq-button-label">Export</span>
        </button>
        <button type="button" class="eq-button" data-variant="accent" data-size="s">
          <span class="eq-button-label">New invoice</span>
        </button>
      </div>
    </div>
  </div>
</header>

Examples

Everything in place

Below 30rem of its own width the title steps down and the actions take a full row, so the header reflows inside a narrow panel as well as on a phone.

larder-storefront

The customer storefront and checkout, deployed from main on every merge.

  • Healthy
  • eu-west-1
  • Deployed 12 min ago by Mira Holt
48 deployments this month, the last 3 promoted to production.
Code
import { Badge } from "@/components/equip/badge";
import { Breadcrumb } from "@/components/equip/breadcrumb";
import { Button } from "@/components/equip/button";
import { Tab, TabList, TabPanel, Tabs } from "@/components/equip/tabs";
import { PageHeader } from "@/components/equip/page-header";

export default function Example() {
  return (
    <Tabs defaultValue="deployments" style={{ width: "100%" }}>
      <PageHeader
        level={2}
        breadcrumb={
          <Breadcrumb
            items={[
              { label: "Acme", href: "#acme" },
              { label: "Projects", href: "#projects" },
              { label: "larder-storefront" },
            ]}
          />
        }
        title="larder-storefront"
        description="The customer storefront and checkout, deployed from main on every merge."
        meta={[
          <Badge key="status" tone="ok" dot>
            Healthy
          </Badge>,
          <span key="region">eu-west-1</span>,
          <span key="deploy">Deployed 12 min ago by Mira Holt</span>,
        ]}
        secondaryActions={
          <Button variant="outline" size="s">
            Visit site
          </Button>
        }
        primaryAction={
          <Button variant="accent" size="s">
            Deploy
          </Button>
        }
        tabs={
          <TabList aria-label="Project">
            <Tab value="deployments">Deployments</Tab>
            <Tab value="logs">Logs</Tab>
            <Tab value="settings">Settings</Tab>
          </TabList>
        }
      />
      <TabPanel value="deployments">48 deployments this month, the last 3 promoted to production.</TabPanel>
      <TabPanel value="logs">Streaming logs from 4 instances.</TabPanel>
      <TabPanel value="settings">Build command, environment variables and domains.</TabPanel>
    </Tabs>
  );
}

Title and one action

Every slot is optional; with only a title and an action the header is a single row.

Team members

People who can see and edit projects in Larder.

Code
import { Button } from "@/components/equip/button";
import { PageHeader } from "@/components/equip/page-header";

export default function Example() {
  return (
    <PageHeader
      level={2}
      title="Team members"
      description="People who can see and edit projects in Larder."
      primaryAction={<Button size="s">Invite people</Button>}
      style={{ width: "100%" }}
    />
  );
}
HTMLworks in any framework, with the part's CSS
<header style="width:100%" class="eq-page-header">
  <div class="eq-page-header-layout">
    <div class="eq-page-header-main">
      <div class="eq-page-header-heading">
        <h2 class="eq-page-header-title">Team members</h2>
        <p class="eq-page-header-description">People who can see and edit projects in Larder.</p>
      </div>
      <div class="eq-page-header-actions">
        <button type="button" class="eq-button" data-variant="solid" data-size="s">
          <span class="eq-button-label">Invite people</span>
        </button>
      </div>
    </div>
  </div>
</header>

Display size

size="l" sets the title from 28px to 40px by the header's own width, for a detail page whose title is the thing people came for.

Order 2041 to Natoora

Seventeen lines, sent at 14:05 for delivery before 07:00 tomorrow.

  • Sent
  • £612.40
  • By Ines Duarte
Code
import { Button } from "@/components/equip/button";
import { PageHeader } from "@/components/equip/page-header";

export default function Example() {
  return (
    <PageHeader
      level={2}
      size="l"
      style={{ width: "100%" }}
      title="Order 2041 to Natoora"
      description="Seventeen lines, sent at 14:05 for delivery before 07:00 tomorrow."
      meta={[<span key="status">Sent</span>, <span key="total">£612.40</span>, <span key="by">By Ines Duarte</span>]}
      secondaryActions={<Button variant="outline">Duplicate</Button>}
      primaryAction={<Button variant="accent">Check in delivery</Button>}
    />
  );
}
HTMLworks in any framework, with the part's CSS
<header style="width:100%" class="eq-page-header" data-size="l">
  <div class="eq-page-header-layout">
    <div class="eq-page-header-main">
      <div class="eq-page-header-heading">
        <h2 class="eq-page-header-title">Order 2041 to Natoora</h2>
        <p class="eq-page-header-description">Seventeen lines, sent at 14:05 for delivery before 07:00 tomorrow.</p>
        <ul class="eq-page-header-meta">
          <li class="eq-page-header-meta-item">
            <span>Sent</span>
          </li>
          <li class="eq-page-header-meta-item">
            <span>£612.40</span>
          </li>
          <li class="eq-page-header-meta-item">
            <span>By Ines Duarte</span>
          </li>
        </ul>
      </div>
      <div class="eq-page-header-actions">
        <button type="button" class="eq-button" data-variant="outline" data-size="m">
          <span class="eq-button-label">Duplicate</span>
        </button>
        <button type="button" class="eq-button" data-variant="accent" data-size="m">
          <span class="eq-button-label">Check in delivery</span>
        </button>
      </div>
    </div>
  </div>
</header>

Props

PropTypeDefaultWhat it does
titleReactNodenoneThe page title.
descriptionReactNodenoneOne or two sentences under the title.
breadcrumbReactNodenoneA Breadcrumb or back link above the title.
metaReactNodenoneShort facts such as status, owner and date. Each child becomes an item in a list.
primaryActionReactNodenoneThe one main action, placed at the end of the row.
secondaryActionsReactNodenoneQuieter actions, placed before the primary one.
tabsReactNodenoneA TabList or other sub navigation along the bottom.
level1 | 21The heading element. 1 for the page title.
size"m" | "l""m"m for an app page. l is a display title that grows from 28px to 40px with the header's width, for a detail or landing page that is mostly this one thing.
...restComponentProps<"header">noneEvery native header attribute.

Anatomy

root.eq-page-headerThe header and the size container its layout queries. data-size="l" for the display title.
layout.eq-page-header-layout
breadcrumb.eq-page-header-breadcrumb
main row.eq-page-header-mainHeading and actions; the actions wrap below when there is no room.
heading.eq-page-header-heading
title.eq-page-header-title
description.eq-page-header-description
actions.eq-page-header-actions
meta.eq-page-header-metaA ul.
meta item.eq-page-header-meta-item
tabs.eq-page-header-tabsScrolls sideways when narrow instead of widening the page.

Notes

  • Renders on the server with no client JavaScript.
  • Below 30rem of its own width the title steps down a size and the actions take a full row, each growing to share it. A size l title keeps its own 28px floor there instead.
  • The primary action comes last in the DOM as well as on screen, so Tab reaches the quieter actions first and the order matches what people see.
  • Inside main a header element is not a banner landmark, which is right for a page header; the h1 is what people navigate by.
  • The meta facts are a list, so assistive tech announces how many there are.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/page-header.tsx74 lines
import { Children, type ComponentProps, type ReactNode } from "react";
import "./page-header.css";

export type PageHeaderProps = Omit<ComponentProps<"header">, "title"> & {
  title: ReactNode;
  /** One or two sentences under the title. */
  description?: ReactNode;
  /** A Breadcrumb, or a back link, above the title. */
  breadcrumb?: ReactNode;
  /** Short facts under the title, such as status, owner and date. Each child becomes one item. */
  meta?: ReactNode;
  /** The one main action, placed last so it sits at the end of the row. */
  primaryAction?: ReactNode;
  /** Quieter actions, placed before the primary one. */
  secondaryActions?: ReactNode;
  /** A Tabs list or other sub navigation along the bottom edge. */
  tabs?: ReactNode;
  /** The heading element. 1 for the page title. */
  level?: 1 | 2;
  /** m for an app page; l is a display title that scales with the header's width, for a page that is mostly this one thing. */
  size?: "m" | "l";
};

export function PageHeader({
  title,
  description,
  breadcrumb,
  meta,
  primaryAction,
  secondaryActions,
  tabs,
  level = 1,
  size = "m",
  className,
  ...rest
}: PageHeaderProps) {
  const Heading = `h${level}` as "h1";
  const hasActions = primaryAction != null || secondaryActions != null;
  const metaItems = Children.toArray(meta);

  return (
    <header
      {...rest}
      className={["eq-page-header", className].filter(Boolean).join(" ")}
      data-size={size === "m" ? undefined : size}
    >
      <div className="eq-page-header-layout">
        {breadcrumb != null && <div className="eq-page-header-breadcrumb">{breadcrumb}</div>}
        <div className="eq-page-header-main">
          <div className="eq-page-header-heading">
            <Heading className="eq-page-header-title">{title}</Heading>
            {description != null && <p className="eq-page-header-description">{description}</p>}
            {metaItems.length > 0 && (
              <ul className="eq-page-header-meta">
                {metaItems.map((item, index) => (
                  <li key={index} className="eq-page-header-meta-item">
                    {item}
                  </li>
                ))}
              </ul>
            )}
          </div>
          {hasActions && (
            <div className="eq-page-header-actions">
              {secondaryActions}
              {primaryAction}
            </div>
          )}
        </div>
        {tabs != null && <div className="eq-page-header-tabs">{tabs}</div>}
      </div>
    </header>
  );
}
components/equip/page-header.css120 lines
@layer equip {
  .eq-page-header {
    box-sizing: border-box;
    display: block;
    min-width: 0;
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-m) / 1.5 var(--eq-font);
    /* The header is the container; its layout answers to its own width, not the viewport. */
    container: eq-page-header / inline-size;
  }

  .eq-page-header-layout {
    display: flex;
    flex-direction: column;
    gap: var(--eq-space-3);
  }

  .eq-page-header-breadcrumb {
    min-width: 0;
  }

  .eq-page-header-main {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-start;
    justify-content: space-between;
    gap: var(--eq-space-3) var(--eq-space-5);
  }

  .eq-page-header-heading {
    flex: 1 1 18rem;
    min-width: 0;
  }

  .eq-page-header-title {
    margin: 0;
    font-size: calc(var(--eq-text-l) * 1.5);
    font-weight: var(--eq-weight-strong);
    line-height: 1.2;
    letter-spacing: -0.016em;
    overflow-wrap: anywhere;
    text-wrap: balance;
  }

  /* Large: a display title from 28px to 40px, following the header's own width at every size. */
  .eq-page-header[data-size="l"] .eq-page-header-title {
    font-size: clamp(calc(var(--eq-text-l) * 1.75), 5.5cqi, calc(var(--eq-text-l) * 2.5));
    line-height: 1.08;
    letter-spacing: -0.025em;
  }

  .eq-page-header-description {
    max-width: 65ch;
    margin: var(--eq-space-1) 0 0;
    color: var(--eq-ink-2);
    text-wrap: pretty;
  }

  .eq-page-header-actions {
    flex: none;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--eq-space-2);
  }

  .eq-page-header-meta {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--eq-space-1) var(--eq-space-4);
    margin: var(--eq-space-2) 0 0;
    padding: 0;
    list-style: none;
    color: var(--eq-ink-2);
    font-size: var(--eq-text-s);
  }

  .eq-page-header-meta-item {
    display: inline-flex;
    align-items: center;
    gap: var(--eq-space-1);
    min-width: 0;
  }

  .eq-page-header-meta-item > svg {
    flex: none;
    width: 14px;
    height: 14px;
  }

  .eq-page-header-tabs {
    min-width: 0;
    margin-top: var(--eq-space-1);
  }

  /* Narrow: a smaller title, and the actions take the whole row so each is easy to reach. */
  @container eq-page-header (width < 30rem) {
    .eq-page-header-title {
      font-size: calc(var(--eq-text-l) * 1.25);
    }

    .eq-page-header-actions {
      flex: 1 1 100%;
    }

    .eq-page-header-actions > * {
      flex: 1 1 auto;
    }

    /* Tabs scroll sideways rather than push the page wider. The padding, taken back by the
       negative margin, leaves room for the focus ring inside the scroller's clip. */
    .eq-page-header-tabs {
      margin: 0 calc(-1 * var(--eq-space-1)) calc(-1 * var(--eq-space-1));
      padding: var(--eq-space-1);
      overflow-x: auto;
      scrollbar-width: none;
    }
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index