Hint
Frame of the passive guide runs: a pulsing beacon on the target that opens the step's content in a popover, with Next / Got it and a close button.
Installation
Terminal
pnpm dlx shadcn@latest add https://ui-registry.com/r/guide/hintSource
The file you get once installed. It is yours: edit the markup, the labels and the styles.
hint.tsx
"use client";
import type { GuideRun, GuideState, Rect } from "@ui-registries/guide";import { type FrameContext, GuideFrame, useGuide, type WithGuideContext,} from "@ui-registries/guide/react";import { type ComponentProps, type CSSProperties, type KeyboardEvent, type ReactNode, type RefCallback, useCallback, useEffect, useId, useRef, useState,} from "react";import { Button } from "@/components/ui/button";import { cn } from "@/lib/utils";
export interface HintLabels { /** Closes the hint for good (`end("dismissed")`). */ close: string; /** Last step: completes the hint (`end("completed")`). */ done: ReactNode; /** Other steps: goes to the next one. */ next: ReactNode; /** Accessible name of the beacon. */ open: string;}
const DEFAULT_LABELS: HintLabels = { open: "Show tip", close: "Dismiss tip", next: "Next", done: "Got it",};
const isPassive = (run: GuideRun) => run.guide.mode === "passive";
const area = (rect: Rect) => rect.width * rect.height;
/** The beacon sits on the largest target (a step may have several). */const majorRect = (rects: Rect[]): Rect | null => rects.reduce<Rect | null>( (major, rect) => (major && area(major) >= area(rect) ? major : rect), null );
export interface HintProps { /** Classes of the beacon (the pulsing dot). */ beaconClassName?: string; /** Classes of the popover. */ className?: string; /** English by default. */ labels?: Partial<HintLabels>;}
const selectRuns = (state: GuideState) => state.runs;
const NONE: ReadonlySet<string> = new Set();
/** A popover stays open across its run's steps, not across runs or a suspension. */const keepsPopover = (runs: GuideRun[], guideId: string) => runs.some( (run) => run.guide.id === guideId && run.step !== null && run.status !== "suspended" );
/** * Frame of the passive runs: a pulsing beacon on the step's target, which * opens the step's content in a popover. Never takes the focus by itself: * the popover gets it after a click on the beacon or on Next (it then stays * open on the next step). Escape while the focus is in the hint dismisses * it. Mount once, inside `<GuideRoot>`, next to `<TourFrame />` (hints are * hidden during a tour, their popover closed). */export function Hint({ beaconClassName, className, labels }: HintProps) { const text = { ...DEFAULT_LABELS, ...labels }; const { state: runs } = useGuide(selectRuns); const [opened, setOpened] = useState(NONE); // Guides whose popover takes the focus once placed (after a user action). const focusRequests = useRef(new Set<string>());
useEffect(() => { for (const guideId of focusRequests.current) { if (!keepsPopover(runs, guideId)) { focusRequests.current.delete(guideId); } } setOpened((current) => { const kept = [...current].filter((guideId) => keepsPopover(runs, guideId) ); return kept.length === current.size ? current : new Set(kept); }); }, [runs]);
const setOpen = useCallback((guideId: string, open: boolean) => { if (open) { focusRequests.current.add(guideId); } else { focusRequests.current.delete(guideId); } setOpened((current) => { if (current.has(guideId) === open) { return current; } const next = new Set(current); if (open) { next.add(guideId); } else { next.delete(guideId); } return next; }); }, []);
const requestFocus = useCallback((guideId: string) => { focusRequests.current.add(guideId); }, []);
const takeFocus = useCallback( (guideId: string) => focusRequests.current.delete(guideId), [] );
return ( <GuideFrame select={isPassive}> {(frame) => ( <HintItem beaconClassName={beaconClassName} className={className} frame={frame} labels={text} onOpenChange={setOpen} open={opened.has(frame.guide.id)} requestFocus={requestFocus} takeFocus={takeFocus} /> )} </GuideFrame> );}
interface HintItemProps { beaconClassName?: string; className?: string; frame: FrameContext; labels: HintLabels; onOpenChange: (guideId: string, open: boolean) => void; open: boolean; requestFocus: (guideId: string) => void; takeFocus: (guideId: string) => boolean;}
function HintItem({ beaconClassName, className, frame, labels, onOpenChange, open, requestFocus, takeFocus,}: HintItemProps) { const guideId = frame.guide.id; const popoverId = useId(); const beaconRef = useRef<HTMLButtonElement>(null); const popoverRef = useRef<HTMLDivElement>(null); const { ref: floatingRef } = frame.floatingProps; const rect = majorRect(frame.rects);
const ref = useCallback<RefCallback<HTMLDivElement>>( (node) => { popoverRef.current = node; const release = floatingRef(node); return () => { popoverRef.current = null; if (typeof release === "function") { release(); } }; }, [floatingRef] );
// Opened by the user (beacon, Next): the focus moves into the popover once // it is placed. On every render: a new step's popover is placed after its // measure. useEffect(() => { if (open && frame.placed && takeFocus(guideId)) { popoverRef.current?.focus({ preventScroll: true }); } });
// A press outside the hint closes the popover (the hint stays). useEffect(() => { if (!open) { return; } const onPointerDown = (event: PointerEvent) => { const target = event.target as Node | null; const inside = popoverRef.current?.contains(target) || beaconRef.current?.contains(target); if (!inside) { onOpenChange(guideId, false); } }; document.addEventListener("pointerdown", onPointerDown); return () => document.removeEventListener("pointerdown", onPointerDown); }, [open, onOpenChange, guideId]);
// The popover is the run's Frame (Escape in it is handled by the manager); // the beacon is outside of it, so it relays Escape itself. const onBeaconKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => { if (event.key === "Escape" && frame.dismissible) { event.preventDefault(); frame.end("dismissed"); } };
const onNext = () => { requestFocus(guideId); frame.next(); };
if (!rect) { return null; }
const beaconStyle: CSSProperties = { position: "fixed", top: rect.y, left: rect.x + rect.width, };
return ( <> <button aria-controls={open ? popoverId : undefined} aria-expanded={open} aria-haspopup="dialog" aria-label={labels.open} className={cn( "group/hint z-50 flex size-6 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full outline-none focus-visible:ring-3 focus-visible:ring-ring/50", beaconClassName )} data-slot="hint-beacon" data-state={open ? "open" : "closed"} onClick={() => onOpenChange(guideId, !open)} onKeyDown={onBeaconKeyDown} ref={beaconRef} style={beaconStyle} type="button" > <span className="relative flex size-2.5"> <span className="absolute inline-flex size-full animate-ping rounded-full bg-primary opacity-75 group-data-[state=open]/hint:hidden motion-reduce:animate-none" /> <span className="relative inline-flex size-2.5 rounded-full bg-primary" /> </span> </button> {open ? ( <div {...frame.floatingProps} className={cn( "fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 flex w-72 max-w-[calc(100vw-2rem)] animate-in flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-popover-foreground text-sm shadow-md outline-hidden ring-1 ring-foreground/10 duration-100 motion-reduce:animate-none", className )} data-slot="hint-popover" id={popoverId} ref={ref} > <div className="flex items-start gap-2"> <div className="flex min-w-0 flex-1 flex-col gap-0.5"> <frame.Content /> </div> {frame.dismissible ? ( <Button aria-label={labels.close} className="-mt-1 -mr-1" onClick={() => frame.end("dismissed")} size="icon-xs" variant="ghost" > <svg aria-hidden="true" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} viewBox="0 0 24 24" > <path d="M18 6 6 18M6 6l12 12" /> </svg> </Button> ) : null} </div> <div className="flex items-center justify-end gap-2"> {frame.total > 1 ? ( <span className="mr-auto text-muted-foreground text-xs"> {frame.index + 1}/{frame.total} </span> ) : null} {frame.isLast ? ( <Button onClick={() => frame.end("completed")} size="xs"> {labels.done} </Button> ) : ( <Button onClick={onNext} size="xs"> {labels.next} </Button> )} </div> </div> ) : null} </> );}
type HintTextProps<T extends "h2" | "p"> = WithGuideContext< Omit<ComponentProps<T>, "id">>;
/** Title of a hint's content, labelling the popover (`aria-labelledby`). */export function HintTitle({ ctx, className, ...props }: HintTextProps<"h2">) { return ( <h2 className={cn("font-medium text-sm", className)} data-slot="hint-title" id={ctx.ids.title} {...props} /> );}
/** Description of a hint's content, describing the popover (`aria-describedby`). */export function HintDescription({ ctx, className, ...props}: HintTextProps<"p">) { return ( <p className={cn("text-muted-foreground text-sm", className)} data-slot="hint-description" id={ctx.ids.description} {...props} /> );}