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.
npx shadcn@latest add @paragon/coach-markAlso installs: button, popover
"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>
</>
);
}