DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
AlertBannerToastEmptyStateTooltipCoachMark
AI agentsContributing
  1. Components
  2. CoachMark

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/ui

When 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 CoachMarkSpotlight with interactive so 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.

Renamed in TES-70. This component used to be exported as Tooltip. The name now belongs to the real hover/focus Tooltip; everything the old card did lives here, unchanged apart from the fixes listed under Migrating.

Installation

Terminal
pnpm add @tessinaui/ui

Usage

example.tsx
import { CoachMark } from "@tessinaui/ui";
example.tsx
{/* 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

PropTypeDescription
badgeReactNodeEyebrow pill above the title — "Tour", "NEW", "Quick tip"
titleReactNodeHeader title text. Also becomes the card's accessible name
titleIconReactNodeIcon shown before the title
subtitleReactNodeSubheader label (inside content area)
subtitleIconReactNodeIcon shown before the subtitle
descriptionReactNodeBody paragraph. Also becomes the card's aria-describedby target
imageReactNodeImage/media slot — rendered in a rounded container
shortcutsstring[]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) => stringWhat AT hears for the step position, in all three stepStyles. Default "Step {current} of {total}", Arabic under dir="rtl"
stringsPartial<CoachMarkStrings>Override the visible step count (step, default "{current} of {total}"), the default step sentence (stepLabel) and the close label (close)
paginationTextstringFree-text footer label

Actions

PropTypeDescription
onClose() => voidShows the close (×) button when provided
closeLabelstringAccessible name for that button. Default "Close", Arabic under dir="rtl"
dismissReactNodeCorner content in place of the close button — a "Skip" button, a "New" badge. You own the dismissing; onClose is not called for it
primaryActionCoachMarkActionLeft footer button (outline style) — e.g. Cancel, Back
secondaryActionCoachMarkActionMiddle footer button (ghost) — e.g. Skip, Maybe later
ctaActionCoachMarkActionRight 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.

PropTypeDefault
showBadgebooleanauto
showImagebooleanauto
showTitlebooleanauto
showSubtitlebooleanauto
showDescriptionbooleanauto
showShortcutsbooleanauto
showStepbooleanauto
showFooterbooleanauto
showClosebooleanauto
showPaginationTextbooleanauto
showPrimaryActionbooleanauto
showSecondaryActionbooleanauto
showCtaActionbooleanauto

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

PropTypeDefaultDescription
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
fullBleedbooleanfalseBelow 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)
classNamestring—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.

PropTypeDefaultDescription
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
arrowOffsetnumber—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

PropTypeDefault
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.

example.ts
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

PropTypeDefaultDescription
openboolean—Whether the step is showing
onOpenChange(open: boolean) => void—
anchorElement | 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
paddingnumber8Breathing room around the anchor inside the hole
radiusnumberanchor's ownHole corner radius. Defaults to the anchor's measured radius grown by padding
interactivebooleanfalsePointer events reach the anchor through the hole, and focus is not contained
side / alignpositioner"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
sideOffsetnumber12
collisionPaddingnumber12Keep the card this far from the viewport edges

CoachMarkTour

PropTypeDefaultDescription
stepsCoachMarkTourStep[]—Each step is a CoachMark's props plus id, anchor, side, align, interactive
openboolean—Whether the tour is running
index / defaultIndexnumber0Controlled 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
iconActionsbooleanfalseDraw next/back as circular icon-only buttons
icons{ next, back }chevrons, mirrored under RTLGlyphs for iconActions. One you pass is drawn as given
keyboardNavigationbooleantrueLeft/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.

PropTypeDefaultDescription
intent / rounded / size / density / fullBleedas CoachMarkas CoachMarkMatch the card it stands in for
showTitle / showDescriptionbooleantrueThe title bar and the description lines
linesnumber2Description lines
showBadge / showImage / showTitleIcon / showSubtitle / showSubtitleIcon / showShortcuts / showClosebooleanfalseOne placeholder per slot
showStepbooleanfalseThe 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
showFooterbooleanfalseThe footer. It draws only the parts switched on beside it
showPaginationText / showPrimaryAction / showSecondaryAction / showCtaActionbooleanfalseThe footer's parts. Each needs showFooter
labelstring"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. CoachMark renders role="group", named by its title (aria-labelledby) and described by its description (aria-describedby). It traps no focus and has no modality, so claiming dialog would promise assistive tech both. With the title hidden, pass aria-label; your own aria-labelledby / aria-describedby win over the generated ones.
  • The spotlight is the dialog. Its scrim makes everything behind it inert, so CoachMarkSpotlight announces 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. interactive lets 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 stepStyle announces "Step 2 of 5", not five unlabelled dots or a bare pair of numbers; the dots are aria-hidden. stepLabel localises 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"); CoachMarkAction requires aria-label in its type when it is icon-only; in iconActions mode the tour's labels are the names.
  • Contrast — every text run, glyph and control border is measured on all five plates in both themes, with the card's own opacity dims composited in: text clears 4.5:1 (lowest 4.61:1, the eyebrow badge on error) 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

KeyContextAction
Tab / Shift+TabCardClose, then the footer actions — natural order. Contained inside a spotlight unless interactive
Enter / SpaceCardActivates the focused action
→ / ←Tour, keyboardNavigation onNext / 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
EscSpotlight or tourCloses it; a tour reports onOpenChange(false, "dismissed")

AT matrix

ATStatus
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
JAWSUNTESTED

One NVDA pass is still owed under QA flow §5.6.

Migrating from Tooltip

BeforeAfter
TooltipCoachMark
TooltipSkeletonCoachMarkSkeleton
tooltipCardVariantscoachMarkVariants
TooltipProps, TooltipAction, TooltipArrowSide, TooltipArrowAlign, TooltipSkeletonPropsCoachMark* 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.
TooltipBreadcrumb

On this page

When to useWhen not to useInstallationUsageWhat it is, and is notOn phonesExamplesDefaultStep stylesA driven tour stepIntentsArrowProduct tourFooter actionsSpotlightCut-out shapes, and the interactive holeA driven tourFull bleed, with a travelling tailIcon-only actionsLink actionsThe dismiss slotFeature rowsOn colorLoadingRTLAPI ReferenceContent slotsActionsVisibility overridesStyleArrowDirectionCoachMarkAction typeCoachMarkSpotlightCoachMarkTourCoachMarkSkeletonAccessibilityKeyboardAT matrixMigrating from Tooltip