Page intro
A slim, dismissible banner that explains an overview page, remembered once closed.
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 One Filled Button Rule
The Thirteen-Fourteen Rule
Anatomy#
- Banner. A
<section>labelled by its title: Well Gray at 70%, 12px corners, 12px top and bottom padding, room on the right for dismiss. - Title. An
h2at 14px semibold, balanced across lines. - Description. 14px muted text up to 42rem wide. One or two sentences.
- Video link. With
video, a 13px indigo link, Watch the 4-minute overview, that opens the video in a dialog. - Learn more. With
learnMoreHref, a 13px indigo link. External URLs open in a new tab. - Dismiss. A 24px ghost icon button in the top-right corner, named Dismiss {title}, with a tooltip.
- 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.
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#
| State | Treatment |
|---|---|
| Unknown | On 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. |
| Shown | Appears without animation on page load. |
| Dismissing | Collapses its height and fades over a 120ms ease-out tween, taking spaceAfter with it. Focus moves to a hidden {title} dismissed note. |
| Dismissed | Renders nothing. Stays dismissed across reloads and in other tabs. |
| Shown again | After resetPageIntro(id), opens from zero height on the moderate spring (160ms). |
| Video open | A dialog titled with the video's title, its duration as the description, and the player already loading. |
Behavior#
- Dismissal is stored in
localStorageunderoration:intro:<id>. Intros with the same id update together in this tab, and astoragelistener 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.spaceAfteris bottom spacing in pixels inside the collapsing element, so the gap below the intro closes with it. A parentgapor margin would stay behind and jump.videotakes{ youtubeId, title, duration }. The link reads the duration rounded to whole minutes (at least 1); the dialog loads the privacy-enhanced player straight away.learnMoreHrefstarting withhttp://orhttps://opens in a new tab withrel="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#
ai.procedures and keep 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
durationasm:ss, such as4: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 itsh2title, under the page's oneh1. - 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.
| Keys | Action |
|---|---|
| Tab | Moves through the links, then Dismiss. |
| Enter | Follows a link, opens the video or dismisses the intro. |
| Esc | Closes the video dialog. |
Design tokens#
| Token | Used for |
|---|---|
--muted | Banner tint at 70% |
--radius-xl | 12px corners |
--foreground | Title |
--muted-foreground | Description and the dismiss icon |
--primary | Video and Learn more link text |
spring.moderate / exit.moderate | 160ms expand when shown again, 120ms collapse on dismiss |
API reference#
PageIntro
The banner. Takes no other props; className lands on the tinted box.
| Prop | Type | Default | Description |
|---|---|---|---|
idRequired | string | No default | Stable, namespaced key for the dismissal, such as ai.procedures. Stored as oration:intro:<id>. |
titleRequired | string | No default | The h2. Also names the dismiss button. |
descriptionRequired | React.ReactNode | No default | One or two sentences. |
illustration | IllustrationName | No default | Shown at 72px from 640px up. |
video | { youtubeId: string; title: string; duration: string } | No default | Adds the video link and its dialog. |
learnMoreHref | string | No default | Adds a Learn more link. External URLs open in a new tab. |
spaceAfter | number | 0 | Bottom spacing in px that collapses with the banner. |
className | string | No default | Classes for the tinted box inside the collapsing section. |
resetPageIntro
resetPageIntro(id?) shows a dismissed intro again, or every intro when no id is given.
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | No default | The 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.