CoachMark
Rich anchored teaching card for product tours, first-run coach marks, and feature callouts. Eyebrow badge, title, image, subtitle, description, keyboard shortcuts, a step indicator as a counter, dots or a bar, up to three footer actions, a close button, 5 intents, 5 radii, and all 12 arrow positions.
pnpm add @tessinaui/uiWhen to use
- A non-obvious control or a new feature. Point at the one thing a first-time reader would not find unaided, at the moment it becomes useful.
- One point per card. A short title and a sentence or two of description; if it needs more, link out from an action.
- A short guided sequence. When the steps depend on each other, drive them
with
CoachMarkTour: a step count, Back and Next, and arrow-key navigation. Keep it to the steps that matter. - A step the reader performs on the real control. Use
CoachMarkSpotlightwithinteractiveso the highlighted control stays operable. - A way out on every card. Always give a close button or a quiet Skip action, so the card never blocks the task.
When not to use
- A hint on hover or focus. Use Tooltip for a label or a short explanation of a control.
- Content opened by a click. Use Popover for anchored content the reader asks for.
- A rich preview of an entity. Use HoverCard.
- A message that must be read or acted on. Use Modal for a blocking decision, or Banner or Toast for status the reader did not ask for.
- Obvious controls, or explaining the whole product at once. A card on self-explanatory UI is noise. Introduce a feature when it is relevant, not every feature up front, and do not repeat a card the reader dismissed.
- Promotion. A coach mark teaches; it does not sell.
Installation
pnpm add @tessinaui/uiUsage
import { CoachMark } from "@tessinaui/ui";{/* Minimal */}
<CoachMark title="Feature name" description="A brief explanation." onClose={() => {}} />
{/* Full product tour step */}
<CoachMark
intent="warning"
arrowSide="bottom"
arrowAlign="center"
badge="Tour"
title="Title Placeholder Text"
titleIcon={<CircleAlert className="w-5 h-5" />}
image={<img src="..." className="w-full aspect-video object-cover" />}
subtitle="Step title"
subtitleIcon={<CircleAlert className="w-5 h-5" />}
step={{ current: 2, total: 8 }}
stepStyle="dots"
shortcuts={["⌘", "U"]}
description="Learn how to use this feature."
onClose={() => {}}
primaryAction={{ label: "Back", leadingIcon: <ChevronLeft />, onClick: handleBack }}
secondaryAction={{ label: "Skip", onClick: handleSkip }}
ctaAction={{ label: "Next", trailingIcon: <ChevronRight />, onClick: handleNext }}
/>What it is, and is not
CoachMark is presentational. It owns no open state, no trigger and no
positioning: a tour driver decides when a step is on screen and where it sits,
and arrowSide / arrowAlign are static props rather than a collision-aware
positioner.
That driver now ships. Three pieces, and you can stop at whichever one you need:
-
CoachMark— the card on its own, placed by you. -
CoachMarkSpotlight— the card plus a scrim with the anchor cut out of it, positioned against that anchor. -
CoachMarkTour— a whole sequence: steps, spotlight, footer controls and arrow-key navigation. -
Hover or focus hint on a control → Tooltip
-
Click-triggered anchored content → Popover
-
Rich hover preview of an entity → HoverCard
It is announced as role="group" named by its own title — deliberately not
role="dialog", because the card traps no focus and has no modality, and
claiming dialog would promise assistive tech both.
The spotlight is different, and it says so. A scrim makes everything behind
it inert, so CoachMarkSpotlight is a dialog and contains focus. The one
exception is interactive, for a step that asks the reader to operate the
highlighted control itself ("Click the People tab") rather than press Next in
the card: it lets pointer events through the cut-out and stops containing
focus. Those two move together — a control you can see but cannot reach is
worse than no highlight at all.
On phones
Real tours pin the card near the viewport edges and move only the arrow between steps, rather than centring an anchored card on each target — eight of the surveyed apps do exactly this.
fullBleed is that shape: below md the card drops its width ladder and its
side radii and becomes a band pinned to the viewport. Because it no longer
travels with its anchor, the tail has to travel instead — arrowOffset slides
it continuously along its edge, where arrowAlign's three stops cannot reach.
It mirrors under dir="rtl" the same way the alignment does.
Examples
Default
The simplest usage — a title, description, and a close button.
Step styles
stepStyle draws the same step three ways: a count beside the subtitle,
centred dots, or a progress bar. All three announce the position as one
sentence ("Step 2 of 5") rather than as five unlabelled nodes or a bare pair of
numbers — including count, whose visible chip stays the compact "2 of 5".
stepLabel localises that sentence for every style, and strings.step the
visible count.
A driven tour step
The card is presentational, so the page owns which step is current. "Skip" is just a ghost secondary action rather than a prop of its own.
Intents
Five intent colors — none, error, warning, success, and info. Every
one paints its fill and publishes the surface contract with it, so nested
buttons, keycaps and the arrow all derive from the card's own ink. The card
also publishes the on-color tone, so a Button, Badge, Link or Key you
pass in title or description adapts to it the same way, inside a
CoachMarkSpotlight too.
Secondary text (description, pagination, step counter) is dimmed only on the
none plate. The four semantic fills spend their whole contrast budget on the
fill itself — error in light mode is 4.77:1 at full ink — so dimming any of
them drops the card's own body copy below WCAG AA's 4.5:1. On those plates the
hierarchy comes from size and position instead.
Arrow
A pointing arrow on any of the four sides via arrowSide. It is the house
speech-bubble tail — the same contour Popover, HoverCard, DropdownMenu, Tooltip
and NavigationMenu point with, so an anchored card reads as part of the same
family. Its apex radius cascades from the card's rounded.
Product tour
A full walkthrough step with icon, subtitle, shortcut, step counter, and navigation buttons.
Footer actions
Footer buttons drive decision flows like confirm/cancel or multi-way choices.
Spotlight
The scrim with the anchor cut out of it. The hole reads the anchor's own border radius, so a pill button gets a pill hole — nothing tells it what shape to punch.
Cut-out shapes, and the interactive hole
Three things the cut-out has to get right: the shape follows the target, the
scrim reuses the modal family's dim | blur | shadow contract, and interactive
decides whether the highlighted control is live.
A driven tour
CoachMarkTour owns the sequence, the placement and the footer controls; the
card underneath is the same presentational CoachMark. Arrow keys move between
steps and stand aside for text inputs.
Full bleed, with a travelling tail
Icon-only actions
The circular advance control seven surveyed apps use. The icon form of
CoachMarkAction requires aria-label in the type, so one of these cannot
ship unnamed.
Link actions
The quiet tier — "Skip", "Learn more", "Previous" as underlined text rather than
buttons. It renders the house Link over a real <button>: advancing a tour is
an action, not navigation.
The dismiss slot
The trailing corner takes a node, not just an X — surveyed tours put a "SKIP"
pill or a "NEW" badge there. closeVariant="solid" is the filled chip that
survives sitting on media or a light scrim.
Feature rows
Three patterns, none of which needs a new prop: an icon-and-label row list in
description, a centred hero glyph in image, and the step counter as an
eyebrow via badge.
On color
The card is its own on-color plate, whatever sits behind it — on the page or inside a spotlight. It publishes on-color to what it holds, so its actions and close button dress from its ink with no tone prop, on the neutral plate and on an intent one alike.
Loading
Skeleton placeholders matching the card chrome across all three sizes.
RTL
dir="rtl" moves the close button to the left corner, packs the footer actions to the left, and flips arrowAlign
start/end on the top and bottom edges. arrowSide names a physical edge, so right stays on the right. With dir
unset the card inherits the page's direction, and CoachMarkSpotlight reads it around its anchor.
An explicit dir="rtl" also defaults the visible step count, the step sentence and the close button's name to Arabic,
and CoachMarkTour's dir="rtl" does the same for Next, Back, Skip and Done. An inherited direction mirrors the layout
and keeps them English. The count is isolated in its own language's direction, so neither form scrambles.
API Reference
Content slots
| Prop | Type | Description |
|---|---|---|
badge | ReactNode | Eyebrow pill above the title — "Tour", "NEW", "Quick tip" |
title | ReactNode | Header title text. Also becomes the card's accessible name |
titleIcon | ReactNode | Icon shown before the title |
subtitle | ReactNode | Subheader label (inside content area) |
subtitleIcon | ReactNode | Icon shown before the subtitle |
description | ReactNode | Body paragraph. Also becomes the card's aria-describedby target |
image | ReactNode | Image/media slot — rendered in a rounded container |
shortcuts | string[] | Keyboard shortcut keys (e.g. ["⌘", "U"]) rendered as Key badges |
step | { current: number; total: number } | Position in a multi-step tour |
stepStyle | "count" | "dots" | "bar" | How step is drawn. Default "count" |
stepLabel | (current, total) => string | What AT hears for the step position, in all three stepStyles. Default "Step {current} of {total}", Arabic under dir="rtl" |
strings | Partial<CoachMarkStrings> | Override the visible step count (step, default "{current} of {total}"), the default step sentence (stepLabel) and the close label (close) |
paginationText | string | Free-text footer label |
Actions
| Prop | Type | Description |
|---|---|---|
onClose | () => void | Shows the close (×) button when provided |
closeLabel | string | Accessible name for that button. Default "Close", Arabic under dir="rtl" |
dismiss | ReactNode | Corner content in place of the close button — a "Skip" button, a "New" badge. You own the dismissing; onClose is not called for it |
primaryAction | CoachMarkAction | Left footer button (outline style) — e.g. Cancel, Back |
secondaryAction | CoachMarkAction | Middle footer button (ghost) — e.g. Skip, Maybe later |
ctaAction | CoachMarkAction | Right footer button (solid) — e.g. Confirm, Save, Next |
Visibility overrides
All slots are shown automatically when their content is provided. Pass false to explicitly hide.
| Prop | Type | Default |
|---|---|---|
showBadge | boolean | auto |
showImage | boolean | auto |
showTitle | boolean | auto |
showSubtitle | boolean | auto |
showDescription | boolean | auto |
showShortcuts | boolean | auto |
showStep | boolean | auto |
showFooter | boolean | auto |
showClose | boolean | auto |
showPaginationText | boolean | auto |
showPrimaryAction | boolean | auto |
showSecondaryAction | boolean | auto |
showCtaAction | boolean | auto |
showClose={false} empties the trailing corner whether it holds the close button or a dismiss node. showTitle={false}
also leaves the card without a name, so pass aria-label with it.
Style
| Prop | Type | Default | Description |
|---|---|---|---|
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Card plate colour |
density | "compact" | "comfortable" | "spacious" | "comfortable" | Card padding (12 / 20 / 28px) |
width | "narrow" | "default" | "wide" | — | Deprecated — renamed to density; it never set a width. Alias for one minor |
fullBleed | boolean | false | Below md, drop the width ladder and the side radii and pin to the viewport |
closeVariant | "ghost" | "solid" | "ghost" | solid is the filled chip for media and light scrims |
rounded | "none" | "sm" | "md" | "lg" | "full" | "full" | Card border radius. Every nested control (buttons, close, keycaps, badge, image) derives its radius from this |
size | "sm" | "md" | "lg" | "lg" | Card width (256 / 320 / 384px). sm stacks footer buttons vertically; md/lg lay them out horizontally. Shortcut key badges scale with size (xs / sm / md) |
className | string | — | Classes for the card. Every other DOM attribute lands on the positioning wrapper around it |
Arrow
12 positions: 4 sides × 3 alignments. Static — the card does not reposition itself.
| Prop | Type | Default | Description |
|---|---|---|---|
arrowSide | "top" | "right" | "bottom" | "left" | "none" | "none" | Which edge the arrow appears on |
arrowAlign | "start" | "center" | "end" | "center" | Alignment along that edge. On the top and bottom edges start/end follow the writing direction |
arrowOffset | number | — | Slide the tail along its edge, in px from the arrowAlign stop. Positive moves toward the end of the edge, so it mirrors under dir="rtl" |
Direction
| Prop | Type | Default |
|---|---|---|
dir | "ltr" | "rtl" | — |
CoachMarkAction type
A union of two shapes. The split is not cosmetic: an icon-only control has no
text to name it, so aria-label is required by the type in that arm rather
than hoped for in a doc comment — the same rule IconButton enforces.
type CoachMarkAction = CoachMarkLabelAction | CoachMarkIconAction;
interface CoachMarkActionBase {
onClick?: () => void;
intent?: "none" | "error" | "warning" | "success" | "info";
rounded?: "none" | "sm" | "md" | "lg" | "full";
disabled?: boolean;
loading?: boolean;
}
interface CoachMarkLabelAction extends CoachMarkActionBase {
label: string;
leadingIcon?: React.ReactNode;
trailingIcon?: React.ReactNode;
/** `"link"` is the quiet tier: underlined text, rendered over a real button. */
variant?: "primary" | "secondary" | "ghost" | "outline" | "link";
}
interface CoachMarkIconAction extends CoachMarkActionBase {
icon: React.ReactNode;
/** Required — an icon-only control has no text to name it. */
"aria-label": string;
variant?: "primary" | "secondary" | "ghost" | "outline";
}CoachMarkSpotlight
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Whether the step is showing |
onOpenChange | (open: boolean) => void | — | |
anchor | Element | RefObject<Element> | null | — | What to cut out and anchor the card to |
scrim | "dim" | "blur" | "shadow" | "dim" | Same three treatments, and the same fills, as Modal |
padding | number | 8 | Breathing room around the anchor inside the hole |
radius | number | anchor's own | Hole corner radius. Defaults to the anchor's measured radius grown by padding |
interactive | boolean | false | Pointer events reach the anchor through the hole, and focus is not contained |
side / align | positioner | "bottom" / "center" | Where the card sits relative to the anchor |
dir | "ltr" | "rtl" | — | The card portals to <body>, so unset, the direction is read around anchor. Pass the card's dir to force one |
sideOffset | number | 12 | |
collisionPadding | number | 12 | Keep the card this far from the viewport edges |
CoachMarkTour
| Prop | Type | Default | Description |
|---|---|---|---|
steps | CoachMarkTourStep[] | — | Each step is a CoachMark's props plus id, anchor, side, align, interactive |
open | boolean | — | Whether the tour is running |
index / defaultIndex | number | 0 | Controlled or uncontrolled step |
onIndexChange | (index, step) => void | — | |
onOpenChange | (open, reason) => void | — | reason is "finished" | "skipped" | "dismissed" |
stepStyle | "count" | "dots" | "bar" | "dots" | |
labels | { next, back, skip, done } | English, Arabic under dir="rtl" | Also the accessible names in iconActions mode |
iconActions | boolean | false | Draw next/back as circular icon-only buttons |
icons | { next, back } | chevrons, mirrored under RTL | Glyphs for iconActions. One you pass is drawn as given |
keyboardNavigation | boolean | true | Left/right arrows move between steps. Under RTL they swap: ← is next |
dir | "ltr" | "rtl" | — | Direction for every step's card and the arrow keys. A step's own dir wins for its card. Unset, the keys follow the document's direction |
CoachMarkSkeleton
The placeholder for a card whose copy or media is still loading. It wears the same plate, radius, width and padding as the card, and each part toggle mirrors a slot of the real one, so the swap moves nothing.
| Prop | Type | Default | Description |
|---|---|---|---|
intent / rounded / size / density / fullBleed | as CoachMark | as CoachMark | Match the card it stands in for |
showTitle / showDescription | boolean | true | The title bar and the description lines |
lines | number | 2 | Description lines |
showBadge / showImage / showTitleIcon / showSubtitle / showSubtitleIcon / showShortcuts / showClose | boolean | false | One placeholder per slot |
showStep | boolean | false | The step indicator, in the form stepStyle names |
stepStyle | "count" | "dots" | "bar" | "count" | Match the card's, or the dot row and the bar are not reserved |
showFooter | boolean | false | The footer. It draws only the parts switched on beside it |
showPaginationText / showPrimaryAction / showSecondaryAction / showCtaAction | boolean | false | The footer's parts. Each needs showFooter |
label | string | "Loading", Arabic under dir="rtl" | What assistive tech announces while it is up |
Accessibility
Pattern: the card has none of its own; CoachMarkSpotlight follows
WAI-ARIA Dialog (Modal).
- The card is a named group, not a dialog.
CoachMarkrendersrole="group", named by its title (aria-labelledby) and described by its description (aria-describedby). It traps no focus and has no modality, so claimingdialogwould promise assistive tech both. With the title hidden, passaria-label; your ownaria-labelledby/aria-describedbywin over the generated ones. - The spotlight is the dialog. Its scrim makes everything behind it inert, so
CoachMarkSpotlightannounces as a dialog, moves focus in and contains it. The card inside keeps its group, so both layers name themselves from the card's own content.interactivelets pointer events through the cut-out and stops containing focus — the two move together, because a control you can see but cannot reach is worse than no highlight. - Position reads as one sentence. Every
stepStyleannounces "Step 2 of 5", not five unlabelled dots or a bare pair of numbers; the dots arearia-hidden.stepLabellocalises it. - A new step re-announces in full — title, description and position — which is what a tour lives or dies on.
- Every control has a name. The close button's comes from
closeLabel(default "Close");CoachMarkActionrequiresaria-labelin its type when it is icon-only; iniconActionsmode the tour'slabelsare the names. - Contrast — every text run, glyph and control border is measured on all five plates in both themes, with the
card's own
opacitydims composited in: text clears 4.5:1 (lowest 4.61:1, the eyebrow badge onerror) and borders 3:1 (WCAG 1.4.11). - Touch — every button on a card, the open spotlight and each tour step included, hit-tests at least 44×44 under a coarse pointer (phones and tablets) and 24×24 under a mouse. That includes link actions and the tour's Skip.
Keyboard
| Key | Context | Action |
|---|---|---|
Tab / Shift+Tab | Card | Close, then the footer actions — natural order. Contained inside a spotlight unless interactive |
Enter / Space | Card | Activates the focused action |
→ / ← | Tour, keyboardNavigation on | Next / previous step. Ignored while focus is in an input, textarea, select or editable content. Under RTL (the tour's dir, the step's, or the document's) they swap: ← is next |
Esc | Spotlight or tour | Closes it; a tour reports onOpenChange(false, "dismissed") |
AT matrix
| AT | Status |
|---|---|
| VoiceOver + Safari (macOS) | Passed 2026-09-09 — on the tour harness a step reads as "web dialog" containing a named "group"; advancing re-announces "Step 2 of 3" with its title; "Step 1 of 3" reads as one sentence; Close, Skip and Next are named |
| VoiceOver + Chrome (macOS) | UNTESTED |
| NVDA + Chrome (Windows) | UNTESTED — no Windows host available |
| JAWS | UNTESTED |
One NVDA pass is still owed under QA flow §5.6.
Migrating from Tooltip
| Before | After |
|---|---|
Tooltip | CoachMark |
TooltipSkeleton | CoachMarkSkeleton |
tooltipCardVariants | coachMarkVariants |
TooltipProps, TooltipAction, TooltipArrowSide, TooltipArrowAlign, TooltipSkeletonProps | CoachMark* equivalents |
rounded="xl" | rounded="lg" |
@tessinaui/ui/tooltip | @tessinaui/ui/coach-mark |
Three behavioural fixes ride along, none of which need a code change on your side:
- The footer buttons now use the shared on-color button recipe instead of a drifted copy of it, so the solid CTA regains its hairline and its disabled label matches the rest of the library.
- The close button clears the 44px touch target on phones, which it previously did not (the footer buttons already did).
- The arrow reads the plate's published fill rather than a second hand-typed copy of all five intent colours, so it can no longer drift off the card.
- The arrow is the shared speech-bubble contour, not a rotated square: its flanks ease out of the card edge instead of meeting it at a hard 45°, which is what makes it match the popover family.