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.
Playground
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 (Mesh, MD Vinyl, Opera, eBay, Grab, Freeform, Fiverr, Whatnot).
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.
This is not an iOS-only pattern. An earlier round of research concluded it was, on the strength of ten web results that all degraded to a centred modal. Re-run against web-specific queries, five of eight are genuinely anchored tours (Navattic, Zoho CRM, Deel, Frame, Weavy). The first finding was an artefact of the queries, not a property of the platform.
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.
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.
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.
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 — Mesh puts a "SKIP" pill there
and Fiverr a "NEW" badge. 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.
Loading
Skeleton placeholders matching the card chrome across all three sizes.
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 (c, t) => `Step ${c} of ${t}` |
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" |
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 |
Style
| Prop | Type | Default | Description |
|---|---|---|---|
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Card plate colour |
density | "compact" | "comfortable" | "spacious" | "comfortable" | Card padding |
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) |
width | "narrow" | "default" | "wide" | "default" | Card padding (12 / 20 / 28px) |
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 |
Direction
| Prop | Type | Default |
|---|---|---|
dir | "ltr" | "rtl" | "ltr" |
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 |
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 | Also the accessible names in iconActions mode |
iconActions | boolean | false | Draw next/back as circular icon-only buttons |
icons | { next, back } | — | Glyphs for iconActions |
keyboardNavigation | boolean | true | Left/right arrows move between steps |
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.