Coach Mark
Navigation

Coach Mark

A pulsing corner beacon on a new feature that opens an origin-aware tip card — acknowledging removes it for good, closing without acknowledging keeps it, and the pulse pauses offscreen.

Install

npx shadcn@latest add @paragon/coach-mark

Also installs: button, popover

coach-mark.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/paragon/ui/button";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/registry/paragon/ui/popover";

const coachStyles = `
@keyframes pg-coach-pulse {
  0% { opacity: 0.55; scale: 1; }
  70%, 100% { opacity: 0; scale: 2.6; }
}
@media (prefers-reduced-motion: reduce) {
  @keyframes pg-coach-pulse { 0%, 100% { opacity: 0; scale: 1; } }
}
`;

const beaconCorner = {
  "top-right": "-top-1 -right-1",
  "top-left": "-top-1 -left-1",
  "bottom-right": "-bottom-1 -right-1",
  "bottom-left": "-bottom-1 -left-1",
} as const;

export interface CoachMarkProps {
  /** The feature being pointed at. */
  children: React.ReactNode;
  title?: React.ReactNode;
  /** Body of the tip card. */
  content?: React.ReactNode;
  /** Small counter chip, e.g. "Tip 1 of 3". */
  stepLabel?: string;
  dismissLabel?: string;
  /** Fires when the user acknowledges the tip — persist it here. */
  onDismiss?: () => void;
  /** Render nothing but the children (tip already seen). */
  disabled?: boolean;
  /** Corner of the target the beacon sits on. */
  beaconPosition?: keyof typeof beaconCorner;
  /** Open the card immediately instead of waiting for a beacon click. */
  defaultOpen?: boolean;
  side?: React.ComponentProps<typeof PopoverContent>["side"];
  align?: React.ComponentProps<typeof PopoverContent>["align"];
  className?: string;
}

/**
 * A single anchored onboarding tip: a quietly pulsing beacon on the corner
 * of a feature that opens an origin-aware card with the explanation and one
 * "Got it" action. Acknowledging removes the beacon for good (persist via
 * `onDismiss`); merely closing the card (Esc, outside click) keeps it —
 * curiosity shouldn't cost the user the tip. The pulse pauses offscreen and
 * flattens to a static dot under reduced motion. First-run element, so it
 * gets the delight budget — but only the halo spends it.
 */
export function CoachMark({
  children,
  title,
  content,
  stepLabel,
  dismissLabel = "Got it",
  onDismiss,
  disabled = false,
  beaconPosition = "top-right",
  defaultOpen = false,
  side = "bottom",
  align = "center",
  className,
}: CoachMarkProps) {
  const reducedMotion = useReducedMotion();
  const [dismissed, setDismissed] = React.useState(false);
  const [open, setOpen] = React.useState(defaultOpen && !disabled);
  const [inView, setInView] = React.useState(true);
  const wrapperRef = React.useRef<HTMLSpanElement>(null);

  // The halo is an infinite loop — park it while offscreen.
  React.useEffect(() => {
    const el = wrapperRef.current;
    if (!el || disabled) return;
    const observer = new IntersectionObserver(([entry]) => {
      setInView(entry?.isIntersecting ?? true);
    });
    observer.observe(el);
    return () => observer.disconnect();
  }, [disabled]);

  const acknowledge = () => {
    setOpen(false);
    setDismissed(true);
    onDismiss?.();
  };

  if (disabled) return <>{children}</>;

  return (
    <>
      <style href="paragon-coach-mark" precedence="paragon">
        {coachStyles}
      </style>
      <span
        ref={wrapperRef}
        data-slot="coach-mark"
        className={cn("relative inline-flex", className)}
      >
        {children}
        <Popover open={open} onOpenChange={setOpen}>
          <AnimatePresence>
            {!dismissed && (
              <PopoverTrigger asChild>
                <motion.button
                  type="button"
                  aria-label={
                    typeof title === "string" ? `Tip: ${title}` : "Open tip"
                  }
                  initial={{ opacity: 0, scale: 0.5 }}
                  animate={{ opacity: 1, scale: 1 }}
                  exit={{
                    opacity: 0,
                    scale: 0.5,
                    transition: { duration: 0.15, ease: [0.4, 0, 1, 1] },
                  }}
                  transition={{ type: "spring", duration: 0.35, bounce: 0.15 }}
                  className={cn(
                    "absolute z-10 flex size-3 items-center justify-center rounded-full outline-none",
                    "after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
                    "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
                    beaconCorner[beaconPosition],
                  )}
                >
                  <span
                    aria-hidden
                    className="pointer-events-none absolute inset-0 rounded-full bg-primary"
                    style={{
                      animation: reducedMotion
                        ? undefined
                        : "pg-coach-pulse 2200ms var(--ease-out) infinite",
                      animationPlayState: inView ? "running" : "paused",
                    }}
                  />
                  <span className="relative size-3 rounded-full bg-primary ring-2 ring-background transition-[scale] duration-150 ease-[var(--ease-out)] hover:scale-110" />
                </motion.button>
              </PopoverTrigger>
            )}
          </AnimatePresence>
          <PopoverContent side={side} align={align} className="w-72 p-4">
            {stepLabel && (
              <p className="text-[11px] font-medium text-muted-foreground tabular-nums">
                {stepLabel}
              </p>
            )}
            {title && (
              <p className={cn("text-sm leading-5 font-semibold", stepLabel && "mt-1")}>
                {title}
              </p>
            )}
            {content && (
              <div className="mt-1.5 text-[13px] leading-5 text-muted-foreground">
                {content}
              </div>
            )}
            <div className="mt-3 flex justify-end">
              <Button size="sm" onClick={acknowledge}>
                {dismissLabel}
              </Button>
            </div>
          </PopoverContent>
        </Popover>
      </span>
    </>
  );
}