Skip to content

Page intro

A slim, dismissible banner that explains an overview page, remembered once closed.

Status
Stable
Category
Content
Adoption
Not used yet
import { PageIntro } from "@oration/canon/components/page-intro";
packages/canon/src/components/page-intro.tsx
import { Button } from "@oration/canon/components/button";import { PageIntro, resetPageIntro } from "@oration/canon/components/page-intro";import { RotateCcwIcon } from "lucide-react";export function Hero() {    // Ids are namespaced to the docs so dismissing here never hides a product intro.    const id = "canon-docs.page-intro.hero";    return (        <div className="flex w-full max-w-2xl flex-col items-start gap-3 text-left">            <div className="w-full">                <PageIntro                    id={id}                    title="Pay suppliers on a schedule"                    description="Payment runs batch approved invoices and pay them together on the day you choose. Open a run to see what's in it, hold an invoice or move the date."                    illustration="schedule"                    learnMoreHref="/design/patterns/education"                />            </div>            <Button                type="button"                variant="ghost"                size="xs"                onClick={() => resetPageIntro(id)}            >                <RotateCcwIcon data-icon="inline-start" aria-hidden="true" />                Show intro again            </Button>        </div>    );}

Usage#

Page intro is a slim, dismissible banner at the top of an overview page. In two sentences it says what this place is and why you'd use it, with an optional short video and a link to the full article. Once someone closes it, it stays closed in that browser, keyed by its id. It is orientation, not news: the common mistake is using it for a release note or a promotion, or putting one on a dense tool screen that people use all day.

When to use

  • At the top of an overview page people meet early and come back to: Procedures, Queues, Callbacks, Cohorts.
  • To explain the idea a page assumes, such as what a procedure is compared to a prompt, with a video or article for the rest.
  • Once per page, directly under the page title.

When not to use

  • For a term or metric that needs explaining in place. Put an info tip beside it. Use Info tip
  • For an announcement, an outage or a warning that has to be read. Use Alert
  • For a confirmation after an action. Use Toast
  • For a page with nothing in it yet. The empty state explains and offers the first action. Use Empty
  • For a dense tool screen such as the inbox or the agent workspace. Leave it bare and use info tips where needed. Use Education and onboarding

A tint, not a card

The intro is a 70% Well Gray tint with 12px corners and no edge or shadow, so it reads as help about the page rather than content on it.

The One Filled Button Rule

The intro carries links and a ghost dismiss button only. The page's filled button stays in the page title.

The Thirteen-Fourteen Rule

The title and description are 14px reading text; the links are 13px. The intro is read once, so it doesn't shrink to dense type.

Anatomy#

  1. Banner. A <section> labelled by its title: Well Gray at 70%, 12px corners, 12px top and bottom padding, room on the right for dismiss.
  2. Title. An h2 at 14px semibold, balanced across lines.
  3. Description. 14px muted text up to 42rem wide. One or two sentences.
  4. Video link. With video, a 13px indigo link, Watch the 4-minute overview, that opens the video in a dialog.
  5. Learn more. With learnMoreHref, a 13px indigo link. External URLs open in a new tab.
  6. Dismiss. A 24px ghost icon button in the top-right corner, named Dismiss {title}, with a tooltip.
  7. Illustration. Optional, 72px wide, on the leading side. Hidden below 640px, and left out of the drawing here.

Examples#

Under the page title

The intro sits between the page title and the first content. spaceAfter puts the gap inside the collapsing element, so dismissing it pulls the stats up with no leftover space.

Callbacks

Calls you and the team promised to return, including ones the voice agent scheduled.

Due today14
Overdue3
Done this week86
import { Button } from "@oration/canon/components/button";import { PageIntro, resetPageIntro } from "@oration/canon/components/page-intro";import { toast } from "@oration/canon/components/toast";import { CalendarPlusIcon, RotateCcwIcon } from "lucide-react";export function UnderPageTitle() {    const id = "canon-docs.page-intro.under-title";    return (        <div className="flex w-full flex-col gap-3">            <div className="w-full rounded-xl bg-background p-6 shadow-border">                {/* In the product this is <PageTitle>. It renders an h1, and this            docs page already has one, so the preview draws the same layout. */}                <div className="mb-6 flex flex-wrap items-start gap-x-6 gap-y-3">                    <div className="min-w-0 flex-1 basis-64">                        <p className="text-xl font-semibold tracking-[-0.015em]">                            Callbacks                        </p>                        <p className="mt-1 max-w-2xl text-sm text-muted-foreground">                            Calls you and the team promised to return, including                            ones the voice agent scheduled.                        </p>                    </div>                    <Button                        type="button"                        size="sm"                        onClick={() =>                            toast.add({                                title: "Callback scheduled",                                description:                                    "Northwind Freight, Tuesday at 10:00 AM CT.",                            })                        }                    >                        <CalendarPlusIcon                            data-icon="inline-start"                            aria-hidden="true"                        />                        Schedule callback                    </Button>                </div>                <PageIntro                    id={id}                    title="Never lose a promised call"                    description="When a queue overflows or a supplier asks to be called back, the callback lands here with its reason and a due time. Claim one to call from your workspace."                    illustration="calls"                    spaceAfter={24}                />                <div className="grid grid-cols-3 overflow-hidden rounded-xl bg-card shadow-border">                    {[                        { label: "Due today", value: "14" },                        { label: "Overdue", value: "3" },                        { label: "Done this week", value: "86" },                    ].map((stat) => (                        <div                            key={stat.label}                            className="flex flex-col gap-1 border-border px-4 py-3 not-first:border-l"                        >                            <span className="text-xs text-muted-foreground">                                {stat.label}                            </span>                            <span className="text-lg font-semibold tabular-nums">                                {stat.value}                            </span>                        </div>                    ))}                </div>            </div>            <Button                type="button"                variant="ghost"                size="xs"                className="self-start"                onClick={() => resetPageIntro(id)}            >                <RotateCcwIcon data-icon="inline-start" aria-hidden="true" />                Show intro again            </Button>        </div>    );}

With a help video

video adds a link with the length in minutes. It opens the video in a dialog that starts loading the player straight away.

import { Button } from "@oration/canon/components/button";import { PageIntro, resetPageIntro } from "@oration/canon/components/page-intro";import { RotateCcwIcon } from "lucide-react";export function WithVideo() {    const id = "canon-docs.page-intro.video";    return (        <div className="flex w-full max-w-2xl flex-col items-start gap-3">            <div className="w-full">                <PageIntro                    id={id}                    title="Keep high-stakes calls on the rails"                    description="Prompts are flexible; procedures are exact. Use one when a call must follow set steps, like verifying a bank change before any detail is read back."                    illustration="procedures"                    video={{                        youtubeId: "wjZofJX0v4M",                        title: "Design your first procedure",                        duration: "4:05",                    }}                    learnMoreHref="/design/patterns/education"                />            </div>            <Button                type="button"                variant="ghost"                size="xs"                onClick={() => resetPageIntro(id)}            >                <RotateCcwIcon data-icon="inline-start" aria-hidden="true" />                Show intro again            </Button>        </div>    );}

Text only

Title and description are enough when the page explains itself once you know the idea. Without an illustration the banner is a single text column.

import { Button } from "@oration/canon/components/button";import { PageIntro, resetPageIntro } from "@oration/canon/components/page-intro";import { RotateCcwIcon } from "lucide-react";export function TextOnly() {    const id = "canon-docs.page-intro.text-only";    return (        <div className="flex w-full max-w-2xl flex-col items-start gap-3">            <div className="w-full">                <PageIntro                    id={id}                    title="Every queue, live"                    description="Waiting counts and service levels update as conversations arrive. Open a queue to change routing, hold music, overflow and callbacks."                />            </div>            <Button                type="button"                variant="ghost"                size="xs"                onClick={() => resetPageIntro(id)}            >                <RotateCcwIcon data-icon="inline-start" aria-hidden="true" />                Show intro again            </Button>        </div>    );}

Showing intros again

resetPageIntro(id) brings one intro back with an expand. A preferences row calls resetPageIntro() with no id to bring all of them back; this demo resets only the docs' own intros.

Page tips

Show the intros you closed at the top of overview pages.

import { Button } from "@oration/canon/components/button";import { resetPageIntro } from "@oration/canon/components/page-intro";import { toast } from "@oration/canon/components/toast";export function ShowTipsAgain() {    // A settings row would call resetPageIntro() with no id to reset every    // intro. The docs reset only their own ids.    const docsIntros = [        "canon-docs.page-intro.hero",        "canon-docs.page-intro.under-title",        "canon-docs.page-intro.video",        "canon-docs.page-intro.text-only",        "canon-docs.page-intro.do",        "canon-docs.page-intro.dont",    ];    return (        <div className="flex w-full max-w-xl items-center justify-between gap-6 rounded-xl bg-card px-4 py-4 shadow-border">            <div className="min-w-0">                <p className="text-sm font-medium">Page tips</p>                <p className="mt-0.5 text-13 text-muted-foreground">                    Show the intros you closed at the top of overview pages.                </p>            </div>            <Button                type="button"                variant="outline"                onClick={() => {                    for (const id of docsIntros) resetPageIntro(id);                    toast.add({                        title: "Page tips are back",                        description: "They show again on each overview page.",                    });                }}            >                Show tips again            </Button>        </div>    );}

States#

States
StateTreatment
UnknownOn the server and the first client render nothing is drawn, until the browser has said whether it was dismissed. It never flashes in and out.
ShownAppears without animation on page load.
DismissingCollapses its height and fades over a 120ms ease-out tween, taking spaceAfter with it. Focus moves to a hidden {title} dismissed note.
DismissedRenders nothing. Stays dismissed across reloads and in other tabs.
Shown againAfter resetPageIntro(id), opens from zero height on the moderate spring (160ms).
Video openA dialog titled with the video's title, its duration as the description, and the player already loading.

Behavior#

  • Dismissal is stored in localStorage under oration:intro:<id>. Intros with the same id update together in this tab, and a storage listener keeps other tabs in step.
  • When storage is blocked, as in some private windows, dismissal is held in memory for the session.
  • resetPageIntro(id) shows one intro again. resetPageIntro() with no id clears every intro in the browser, which is what a Show page tips again setting wants and a demo never does.
  • spaceAfter is bottom spacing in pixels inside the collapsing element, so the gap below the intro closes with it. A parent gap or margin would stay behind and jump.
  • video takes { youtubeId, title, duration }. The link reads the duration rounded to whole minutes (at least 1); the dialog loads the privacy-enhanced player straight away.
  • learnMoreHref starting with http:// or https:// opens in a new tab with rel="noopener noreferrer". Other paths open in place.
  • Only an intro that was dismissed and shown again animates in. The first reveal on load doesn't.

Do and don't#

Do. Say what the place is for and when you'd use it, in two sentences, and link the video or article for the rest.
Don't. Use the intro for a release note or a promotion, with an exclamation point and a filled call to action.
Do. Use one intro per page, under the page title, on overview pages.
Don't. Stack intros, or put one above the inbox or a data grid people work in all day.
Do. Give each intro a stable, namespaced id such as ai.procedures and keep it.
Don't. Share an id across pages, so closing one hides both, or change it every release, so it comes back after people closed it.

Content#

  • The title says what you can do here, as an outcome, in sentence case without a period: Keep high-stakes calls on the rails, Every queue, live.
  • Don't repeat the page title. The page title names the place; the intro says why it matters.
  • The description is one or two sentences: what this is, then when you'd use it or what to open next.
  • Write the video's duration as m:ss, such as 4:05. The link text is generated from it.
  • No exclamation points, no New, no marketing. If it changes, it isn't an intro.

Accessibility#

  • The banner is a <section> labelled by its h2 title, under the page's one h1.
  • Dismiss is an icon-only button named Dismiss {title}, with a Dismiss tooltip and a 24px target.
  • After dismissing, focus moves to a visually hidden {title} dismissed note, so keyboard and screen reader users aren't dropped at the top of the page.
  • The video link is a button that opens a dialog. The dialog is titled with the video's title and described by its duration; Esc closes it and focus returns to the link.
  • External Learn more links announce (opens in a new tab), and both links include the intro title for screen readers.
  • The illustration is decorative.
Keyboard interactions
KeysAction
TabMoves through the links, then Dismiss.
EnterFollows a link, opens the video or dismisses the intro.
EscCloses the video dialog.

Design tokens#

Design tokens
TokenUsed for
--mutedBanner tint at 70%
--radius-xl12px corners
--foregroundTitle
--muted-foregroundDescription and the dismiss icon
--primaryVideo and Learn more link text
spring.moderate / exit.moderate160ms expand when shown again, 120ms collapse on dismiss

API reference#

PageIntro

The banner. Takes no other props; className lands on the tinted box.

Props of PageIntro
PropTypeDefaultDescription
idRequiredstringNo defaultStable, namespaced key for the dismissal, such as ai.procedures. Stored as oration:intro:<id>.
titleRequiredstringNo defaultThe h2. Also names the dismiss button.
descriptionRequiredReact.ReactNodeNo defaultOne or two sentences.
illustrationIllustrationNameNo defaultShown at 72px from 640px up.
video{ youtubeId: string; title: string; duration: string }No defaultAdds the video link and its dialog.
learnMoreHrefstringNo defaultAdds a Learn more link. External URLs open in a new tab.
spaceAfternumber0Bottom spacing in px that collapses with the banner.
classNamestringNo defaultClasses for the tinted box inside the collapsing section.

resetPageIntro

resetPageIntro(id?) shows a dismissed intro again, or every intro when no id is given.

Props of resetPageIntro
PropTypeDefaultDescription
idstringNo defaultThe intro to show again. Omit to reset them all.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

Because nothing renders until the browser has read storage, the intro appears just after hydration on every visit and pushes the page down once. Reserve the space or accept the shift.

The collapse and expand animate height through Motion. The app-wide MotionConfig reducedMotion="user" only drops transforms, so the height animation still plays under reduced motion.

className lands on the inner tinted box, so a margin passed through it doesn't collapse with the banner. Use spaceAfter for the gap below.