Education and onboarding
Pages that explain themselves, info tips beside jargon, empty states that teach, and no tours.
The problem#
Oration is four apps full of AP and contact-center vocabulary: payment runs, W-9s, warm transfers, TTFB. New people need to learn it, and experienced people need it out of the way.
The usual fix is a tour: a modal that greets you, then coach marks that point at controls and count steps. Tours interrupt the work to teach it, get dismissed unread, and are gone when the question actually comes up a week later.
Canon puts the explanation where the question arises, one glance or one click away, and lets people keep working.
The solution#
Every page answers what it is and why you'd use it within its first viewport. Jargon explains itself in place. Empty places teach their first action.
| Place | Explains itself with | Never |
|---|---|---|
| Every page | Its title and a one-sentence description of what it is for. | A page that opens on a bare grid with no words. |
| Overview pages (Home, an app's home, a section overview) | A Page intro under the title: what this place is, why it matters, a short video and an article. One per page, dismissible. | More than one intro, or an intro on a tool screen. |
| Dense tool screens (grids, canvases, inboxes, editors) | The header title and description only, with Info tips beside section titles and metrics. | A banner that pushes the tool down. |
| Config sections and jargon (TTFB, warm transfer, interruption word threshold) | An Info tip beside the term: its name, one or two sentences, an optional video. | A tooltip with a paragraph in it. |
| Empty places | An empty state that says what it is, gives one example and offers the first action. | “Nothing here yet.” |
| Get started | A checklist that says why each task matters, with a quiet check and a progress ring. | Confetti, streaks or badges. |
The first viewport#
Title, description and, on overview pages, a slim intro. Together they answer what and why before anyone scrolls.
A page intro on an overview page
Dismiss it and it stays dismissed under oration:intro:<id> in localStorage. Focus moves to a hidden note so keyboard users don't lose their place.
Payment runs
Batches of approved invoices that Cedarline pays on a schedule.
- Thursday, October 114 invoices$61,240.18
- Thursday, October 89 invoices$22,906.40
import { Button } from "@oration/canon/components/button";import { PageIntro, resetPageIntro } from "@oration/canon/components/page-intro";export function OverviewIntro() { const id = "canon-education-payment-runs"; return ( <div className="flex w-full max-w-2xl flex-col gap-4 text-left"> <div className="flex flex-col gap-1"> <h3 className="text-xl font-semibold tracking-[-0.015em] text-foreground"> Payment runs </h3> <p className="max-w-[42rem] text-sm text-pretty text-muted-foreground"> Batches of approved invoices that Cedarline pays on a schedule. </p> </div> <PageIntro id={id} title="Pay suppliers in batches, on your schedule" description="A run collects approved invoices, takes early-pay discounts when cash allows and sends remittance advice to every supplier it pays." illustration="schedule" video={{ youtubeId: "aircAruvnKk", title: "Plan your first payment run", duration: "3:12", }} learnMoreHref="/design/components/page-intro" /> <ul className="overflow-hidden rounded-xl bg-card shadow-border"> {[ ["Thursday, October 1", "14 invoices", "$61,240.18"], ["Thursday, October 8", "9 invoices", "$22,906.40"], ].map(([date, count, total]) => ( <li key={date} className="flex h-11 items-center gap-3 border-b border-border px-4 text-13 last:border-b-0" > <span className="flex-1 font-medium text-foreground"> {date} </span> <span className="text-muted-foreground tabular-nums"> {count} </span> <span className="w-24 text-right font-medium tabular-nums"> {total} </span> </li> ))} </ul> <Button variant="outline" size="sm" className="self-start" onClick={() => resetPageIntro(id)} > Show the intro again </Button> </div> );}- The description under the title is one sentence, up to 42rem: what the page holds, not what the product is. Batches of approved invoices that Cedarline pays on a schedule.
- A page intro goes on overview pages only, at most one per page, directly under the title. Its title states the benefit; its description says how.
- It's a 70% Well Gray tint well with 12px corners, an optional 72px illustration, and links in indigo: Watch the 3-minute overview and Learn more.
- Never on a tool screen. A grid, canvas or inbox explains itself in its header and with info tips.
Jargon, in place#
Put an info tip beside any term a new person would have to look up. It opens on click, or after 350ms of hovering with a pointer, and closes when focus leaves.
Info tips on voice agent settings
Hover or click the (i) beside each label. Warm transfer carries a help video that loads only when played.
import { InfoTip } from "@oration/canon/components/info-tip";import { Input } from "@oration/canon/components/input";import { SettingsGroup, SettingsRow } from "@oration/canon/components/settings-section";import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function JargonTips() { const [warm, setWarm] = React.useState(true); const [threshold, setThreshold] = React.useState("3"); const [ttfb, setTtfb] = React.useState("800"); return ( <SettingsGroup className="w-full max-w-xl text-left"> <SettingsRow inline label="Warm transfer" htmlFor="edu-warm" description="Nora briefs the person before handing the caller over." info={ <InfoTip title="Warm transfer" description="Nora stays on the line, introduces the caller and passes a short summary before handing over. In a cold transfer the caller goes straight through and repeats themselves." video={{ youtubeId: "wjZofJX0v4M", title: "Hand calls to your team", duration: "2:48", }} /> } > <Switch id="edu-warm" checked={warm} onCheckedChange={(checked) => { setWarm(checked); toast.add({ title: checked ? "Warm transfer on" : "Warm transfer off", description: checked ? "Nora will brief your team before handing over." : "Callers go straight through to your team.", }); }} /> </SettingsRow> <SettingsRow label="Interruption word threshold" htmlFor="edu-threshold" description="Words a caller says before Nora stops talking." info={ <InfoTip title="Interruption word threshold" description="How many words a caller has to say before Nora stops and listens. Lower feels more responsive; higher ignores “mm-hm” and coughs. Most agents use 2 or 3." /> } > <Input id="edu-threshold" inputMode="numeric" value={threshold} onChange={(event) => setThreshold(event.target.value)} className="w-20 tabular-nums" /> </SettingsRow> <SettingsRow label="TTFB target" htmlFor="edu-ttfb" description="Alert when the median goes over this." info={ <InfoTip title="TTFB" description="Time to first byte: how long after the caller stops speaking until Nora's first audio plays. Under 800 ms feels like a natural pause." learnMoreHref="/design/components/latency-timeline" /> } > <div className="flex items-center gap-2"> <Input id="edu-ttfb" inputMode="numeric" value={ttfb} onChange={(event) => setTtfb(event.target.value)} className="w-20 tabular-nums" /> <span className="text-13 text-muted-foreground">ms</span> </div> </SettingsRow> </SettingsGroup> );}| Part | Write |
|---|---|
| Title | The term itself, as people see it in the UI: TTFB, Warm transfer. |
| Description | One or two sentences: what it means here, then why it matters or a number to aim for. Under 800 ms feels like a natural pause. |
| Video | Optional. A help video under five minutes, with its real duration. |
| Learn more | Optional. A link to the full article when one exists, never to a generic help home. |
Empty states that teach#
An empty place is the moment someone is most ready to learn what it's for. Say what it is, give one concrete example, and offer the first action.
First use and filtered to nothing
First use teaches; filtered to nothing names the filters that hid everything and offers to clear them.
Approval rules
No approval rules yet
Invoices
No invoices match these filters
import { Button } from "@oration/canon/components/button";import { EmptyState } from "@oration/canon/components/data-state";import { toast } from "@oration/canon/components/toast";export function TeachingEmpty() { return ( <div className="grid w-full gap-3 text-left md:grid-cols-2"> <div className="flex min-w-0 flex-col overflow-hidden rounded-xl bg-card shadow-border"> <p className="border-b border-border px-4 py-2.5 text-13 font-medium"> Approval rules </p> <EmptyState illustration="flows" title="No approval rules yet" description="Rules send invoices to the right person before they're paid. For example, invoices over $25,000 from a new supplier go to Maya Okafor." action={ <> <Button onClick={() => toast.add({ title: "New approval rule", description: "Start with who approves and the amount.", }) } > Create a rule </Button> <Button variant="outline" onClick={() => toast.add({ title: "Templates", description: "Three starting points for AP teams.", }) } > Use a template </Button> </> } /> </div> <div className="flex min-w-0 flex-col overflow-hidden rounded-xl bg-card shadow-border"> <p className="border-b border-border px-4 py-2.5 text-13 font-medium"> Invoices </p> <EmptyState illustration="filters" title="No invoices match these filters" description="On hold and Northwind Freight hide all 48 open invoices." action={ <Button variant="outline" onClick={() => toast.add({ title: "Filters cleared", description: "Showing all 48 open invoices.", }) } > Clear filters </Button> } /> </div> </div> );}| Part | Example |
|---|---|
| What it is | Rules send invoices to the right person before they're paid. |
| One example | For example, invoices over $25,000 from a new supplier go to Maya Okafor. |
| The first action | Create a rule, with Use a template beside it when templates exist. |
The illustration comes from the Illustration set, never a bare icon in a circle. See Loading, empty and error for how the empty state fits with the skeleton and the error.
Help videos#
A video facade shows a thumbnail, a play button and the duration. The privacy-enhanced YouTube player loads only when someone presses play.
A video facade
import { VideoFacade } from "@oration/canon/components/video-facade";export function HelpVideo() { return ( <figure className="flex w-full max-w-sm flex-col gap-2 text-left"> <VideoFacade video={{ youtubeId: "zjkBMFhNj_g", title: "Build your first voice agent", duration: "3:12", }} /> <figcaption className="flex flex-col gap-0.5"> <span className="text-13 font-medium text-foreground"> Build your first voice agent </span> <span className="text-xs text-muted-foreground tabular-nums"> 3:12, from the Agents Platform help </span> </figcaption> </figure> );}- Videos live inside an info tip, a page intro's dialog or a help article, never autoplaying on a page.
- Title the video as the task it teaches (Build your first voice agent) and show its real duration in tabular figures.
- The play button is named with the title and the spoken duration: Play video: Build your first voice agent, 3 minutes 12 seconds.
Stand-in videos
Until Oration's own help videos exist, the suite uses four public explainers as stand-ins, titled as if they were Oration videos. Don't add new video IDs without replacing them all.
Rewarding progress#
Get started is the one place that celebrates, and it does so quietly: a check that draws itself and a segmented ring that fills by one step.
A setup checklist
Mark a task done to see the check and the ring move. Undo puts it back.
Get started
2 of 5 done
- Done
Connect your ERP
Invoices and suppliers sync from NetSuite every hour.
- Done
Import suppliers
So Nora can greet callers by company name.
Set approval rules
Large invoices reach the right approver before they're paid.
Build your first voice agent
Answer “Where is my payment?” calls around the clock.
Invite your team
Priya, Tomás and Jordan pick up the calls Nora transfers.
import { Button } from "@oration/canon/components/button";import { RadialChart } from "@oration/canon/components/radial-chart";import { spring } from "@oration/canon/lib/springs";import { cn } from "@oration/canon/lib/utils";import { AnimatePresence, motion, useReducedMotion } from "motion/react";import * as React from "react";export function GetStartedProgress() { const reduceMotion = useReducedMotion(); const tasks = [ { id: "erp", title: "Connect your ERP", why: "Invoices and suppliers sync from NetSuite every hour.", }, { id: "suppliers", title: "Import suppliers", why: "So Nora can greet callers by company name.", }, { id: "rules", title: "Set approval rules", why: "Large invoices reach the right approver before they're paid.", }, { id: "agent", title: "Build your first voice agent", why: "Answer “Where is my payment?” calls around the clock.", }, { id: "team", title: "Invite your team", why: "Priya, Tomás and Jordan pick up the calls Nora transfers.", }, ]; const [done, setDone] = React.useState<string[]>(["erp", "suppliers"]); const count = done.length; return ( <section aria-labelledby="edu-get-started" className="w-full max-w-xl overflow-hidden rounded-xl bg-card text-left shadow-border" > <header className="flex items-center gap-3 border-b border-border px-4 py-3"> <RadialChart value={count / tasks.length} segments={tasks.length} size={40} ariaLabel="Setup progress" label={count} /> <div className="flex min-w-0 flex-col"> <h3 id="edu-get-started" className="text-sm font-semibold text-foreground" > Get started </h3> <p className="text-xs text-muted-foreground tabular-nums"> {count} of {tasks.length} done </p> </div> </header> <ul> {tasks.map((task) => { const isDone = done.includes(task.id); return ( <li key={task.id} className="flex items-center gap-3 border-b border-border px-4 py-3 last:border-b-0" > <span aria-hidden="true" className="relative inline-grid size-5 shrink-0 place-items-center" > <AnimatePresence initial={false} mode="popLayout" > {isDone ? ( <motion.span key="done" initial={ reduceMotion ? false : { scale: 0.6, opacity: 0 } } animate={{ scale: 1, opacity: 1 }} exit={{ opacity: 0, transition: { duration: 0.12 }, }} transition={spring.moderate} className="grid size-5 place-items-center rounded-full bg-foreground text-background" > <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3" > <motion.path d="m4 8.4 2.6 2.6L12 5.4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" initial={ reduceMotion ? false : { pathLength: 0 } } animate={{ pathLength: 1 }} transition={{ duration: 0.24, ease: [ 0.23, 1, 0.32, 1, ], delay: 0.04, }} /> </svg> </motion.span> ) : ( <motion.span key="todo" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { duration: 0.12 }, }} className="size-5 rounded-full border-[1.5px] border-foreground/20" /> )} </AnimatePresence> </span> <div className="min-w-0 flex-1"> <p className={cn( "text-13 font-medium transition-colors duration-150", isDone ? "text-muted-foreground" : "text-foreground", )} > {task.title} </p> <p className="mt-0.5 text-xs text-pretty text-muted-foreground"> {task.why} </p> </div> {isDone ? ( <> <span className="text-xs text-muted-foreground"> Done </span> <Button variant="ghost" size="sm" aria-label={`Undo, mark ${task.title.toLowerCase()} as not done`} onClick={() => setDone((current) => current.filter( (item) => item !== task.id, ), ) } > Undo </Button> </> ) : ( <Button variant="outline" size="sm" aria-label={`Mark ${task.title.toLowerCase()} done`} onClick={() => setDone((current) => [ ...current, task.id, ]) } > Mark done </Button> )} </li> ); })} </ul> </section> );}- The check is an ink disc whose path draws over 240ms on the house ease-out. Under reduced motion it appears drawn.
- The ring is a Radial chart with one segment per task, in Ink 65, never indigo.
- Each task says why it matters in one line, and every task can be skipped or undone.
What education never does#
Guided tours, coach marks, modal walkthroughs and confetti.
No tours
Empty states, not walkthroughs#
No approval rules yet
Welcome to Approvals
Let's take a quick tour of everything you can do here.
A quiet check, not confetti#
You're crushing it
3 of 5 tasks done. Keep the streak alive
- Lead with what the thing does for the person, then how: Pay suppliers in batches, on your schedule.
- Use Cedarline's world in every example: suppliers, invoices, remittances, W-9s. Never Lorem or Item 1.
- Give numbers people can act on: Under 800 ms feels like a natural pause, not Lower is better.
- Name the next action as a verb and an object: Create a rule, Connect your ERP.
- Don't apologize for emptiness or congratulate for progress.
Accessibility#
Help has to reach the people who need it most, including those who never hover.
- The info tip trigger is a 16px button with a 24px hit area, named About TTFB. It opens on click and on keyboard activation, not only on hover.
- Info tip content is a popover with a title, so screen readers announce it as a dialog with a name.
- Dismissing a page intro moves focus to a screen-reader note (Pay suppliers in batches, on your schedule dismissed) instead of dropping it to the page.
- Video facades name the video and its spoken duration; the player takes focus once it loads.
- The progress ring is
role="meter"with a value text, and each task's state is also written as Done. - The check animation draws only when motion is allowed.
Components#
The parts this pattern is built from.
- Page introA slim, dismissible banner that explains an overview page, remembered once closed.
- Info tipThe i-button popover that explains jargon in place, with an optional help video.
- Data stateOne wrapper that renders a component's skeleton, empty, error and success states.
- IllustrationHairline spot illustrations for empty states, errors and education, animated with CSS on mount and hover.
- Video facadeA lightweight video poster that loads the player only when someone presses play.
- Radial chartA ring that shows one ratio, with a centered label and optional segments.
- Settings sectionThe rhythm of every settings page: sections, groups and label-left rows.