DocumentationComponentsTheme CreatorGitHub
Theme CreatorGitHubIntroduction
InstallationUsageTheming
ComponentsAccordionAction SheetAlertAlertDialogArea ChartAspectRatioAvatarBadgeBannerBar ChartBottom NavBreadcrumbButtonButtonGroupCalendarCardCarouselChartChatBubbleChatBubbleNewCheckboxChipCoachMarkCodeBlockCollapsibleColor PickerComboboxCommandContainerContextMenuDate PickerDividerDrawerDropdown MenuEmptyStateFABFieldFieldsetFile UploadFlexFormGridHoverCardIconButtonLabelLine ChartLinkMenubarMeterModalNavigation MenuNumberFieldOTP InputPaginationPickerPie ChartPopoverProgressPromptInputRadar ChartRadial ChartRadioRatingScroll AreaSearchSegmentedControlSelectShortcutSidebarSkeletonSliderCircularSliderMediaTrimmerSpacerSpinnerSplit ButtonStackStatusStepperSurfaceSwitchTableTabsTextareaTime PickerToastToggleButtonToggleGroupTokenizerToolbarTooltipTop Header DesktopTop Header Mobile
Contributing
Components

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

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

pnpm add @tessinaui/ui

Usage

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

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 (c, t) => `Step ${c} of ${t}`
paginationTextstringFree-text footer label

Actions

PropTypeDescription
onClose() => voidShows the close (×) button when provided
closeLabelstringAccessible name for that button. Default "Close"
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

Style

PropTypeDefaultDescription
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
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)
width"narrow" | "default" | "wide""default"Card padding (12 / 20 / 28px)

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

Direction

PropTypeDefault
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

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
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 }EnglishAlso the accessible names in iconActions mode
iconActionsbooleanfalseDraw next/back as circular icon-only buttons
icons{ next, back }—Glyphs for iconActions
keyboardNavigationbooleantrueLeft/right arrows move between steps

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

On this page

PlaygroundInstallationUsageWhat 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 rowsLoadingAPI ReferenceContent slotsActionsVisibility overridesStyleArrowDirectionCoachMarkAction typeCoachMarkSpotlightCoachMarkTourMigrating from Tooltip