@mshafiqyajid/react-spotlight
Full-screen overlay with an SVG cutout that highlights any element on the page. Supports single-step highlights, multi-step guided tours with built-in prev/next navigation, pulse animation, backdrop blur, eight placement options, focus trap, Escape to close, scroll-into-view, and live tracking via ResizeObserver. Controlled and uncontrolled. Zero dependencies.
Playground #
import { SpotlightStyled } from "@mshafiqyajid/react-spotlight/styled";
import "@mshafiqyajid/react-spotlight/styles.css";
<SpotlightStyled />Install #
npm install @mshafiqyajid/react-spotlight Quick start #
Pass a ref (or a CSS selector string) as target to identify the element the overlay should cut out around. Render your tooltip content as children.
import { useRef, useState } from "react";
import { SpotlightStyled } from "@mshafiqyajid/react-spotlight/styled";
import "@mshafiqyajid/react-spotlight/styles.css";
function App() {
const [open, setOpen] = useState(false);
const btnRef = useRef<HTMLButtonElement>(null);
return (
<>
<button ref={btnRef} onClick={() => setOpen(true)}>
Show spotlight
</button>
<SpotlightStyled
target={btnRef}
open={open}
onOpenChange={setOpen}
placement="bottom"
>
<div>
<strong>Welcome!</strong>
<p>This button starts the tour.</p>
<button onClick={() => setOpen(false)}>Got it</button>
</div>
</SpotlightStyled>
</>
);
} Multi-step tour #
Pass a steps array to turn the spotlight into a guided tour. Each step specifies its own target and optionally overrides padding, radius, placement, and content. Prev / Next navigation and a step counter are rendered automatically inside the content panel. The Done button appears on the last step and closes the tour.
import { useRef, useState } from "react";
import { SpotlightStyled } from "@mshafiqyajid/react-spotlight/styled";
import "@mshafiqyajid/react-spotlight/styles.css";
import type { SpotlightStep } from "@mshafiqyajid/react-spotlight";
function GuidedTour() {
const [open, setOpen] = useState(false);
const navRef = useRef<HTMLElement>(null);
const searchRef = useRef<HTMLElement>(null);
const actionRef = useRef<HTMLElement>(null);
const steps: SpotlightStep[] = [
{
target: navRef,
placement: "right",
content: <p>Use the sidebar to navigate between sections.</p>,
},
{
target: searchRef,
padding: 8,
content: <p>Search anything from here.</p>,
},
{
target: actionRef,
placement: "bottom",
content: <p>Create a new item with this button.</p>,
},
];
return (
<>
{/* ... your UI elements with the refs attached ... */}
<button onClick={() => setOpen(true)}>Start tour</button>
{/* SpotlightStyled renders prev/next/done nav automatically */}
<SpotlightStyled
target={navRef}
open={open}
onOpenChange={setOpen}
steps={steps}
pulse
/>
</>
);
} Control the active step yourself with step + onStepChange:
const [currentStep, setCurrentStep] = useState(0);
<SpotlightStyled
target={ref}
open={open}
onOpenChange={setOpen}
steps={steps}
step={currentStep}
onStepChange={setCurrentStep}
/> Pulse animation #
Add pulse to draw a ring that expands outward from the cutout, directing the user's attention. The animation is suppressed automatically when the user prefers reduced motion.
<SpotlightStyled target={ref} open={open} onOpenChange={setOpen} pulse>
<p>Look here!</p>
</SpotlightStyled> Backdrop blur #
Set backdropBlur (px) to apply backdrop-filter: blur() behind the overlay. Reduce overlayColor opacity alongside it for a frosted-glass effect.
<SpotlightStyled
target={ref}
open={open}
onOpenChange={setOpen}
backdropBlur={8}
overlayColor="rgba(0,0,0,0.4)"
>
<p>Frosted glass overlay</p>
</SpotlightStyled> Placement #
Eight positions control where the content panel appears relative to the target. In multi-step mode each step can override this independently.
| Value | Panel position |
|---|---|
"bottom" (default) | Centered below the target |
"top" | Centered above the target |
"left" / "right" | Vertically centered beside the target |
"bottom-start" / "bottom-end" | Below, aligned to start or end edge |
"top-start" / "top-end" | Above, aligned to start or end edge |
CSS selector target #
Pass a CSS selector string when you don't own the target element's ref — useful for highlighting third-party components or dynamically inserted DOM nodes.
<SpotlightStyled
target="#my-nav-button"
open={open}
onOpenChange={setOpen}
>
<p>This is the navigation button.</p>
</SpotlightStyled> Trigger slot #
The trigger prop renders a wrapper that toggles the spotlight on click — no state management required for simple use-cases.
import { useRef } from "react";
import { SpotlightStyled } from "@mshafiqyajid/react-spotlight/styled";
import "@mshafiqyajid/react-spotlight/styles.css";
function App() {
const featureRef = useRef<HTMLDivElement>(null);
return (
<>
<div ref={featureRef}>Feature area</div>
<SpotlightStyled
target={featureRef}
trigger={<button>Highlight</button>}
>
<p>Now you see me.</p>
</SpotlightStyled>
</>
);
} Accessibility #
The overlay carries role="dialog", aria-modal="true", and aria-label="Spotlight". Focus is trapped inside while open — Tab / Shift+Tab cycle through focusable children. On close, focus returns to the previously focused element. Escape closes by default (set closeOnEscape={false} to opt out). Multi-step prev/next/done buttons carry descriptive aria-label values and the disabled prev button on step 0 is excluded from the focus cycle.
{/* Escape and overlay click disabled — only explicit action closes */}
<SpotlightStyled
target={ref}
open={open}
onOpenChange={setOpen}
closeOnEscape={false}
closeOnOverlayClick={false}
>
<div>
<p>You must click the button to continue.</p>
<button onClick={() => setOpen(false)}>Next</button>
</div>
</SpotlightStyled> Headless #
Use useSpotlight directly when you need full control over the overlay markup — for example to use a custom SVG mask or integrate with an existing modal system.
import { useSpotlight } from "@mshafiqyajid/react-spotlight";
import { createPortal } from "react-dom";
import { useRef } from "react";
function MySpotlight() {
const targetRef = useRef<HTMLButtonElement>(null);
const { isOpen, open, close, targetRect, overlayProps, spotlightProps, padding, radius } =
useSpotlight({ target: targetRef, placement: "bottom" });
return (
<>
<button ref={targetRef} onClick={open}>Highlight me</button>
{isOpen && createPortal(
<div {...overlayProps}>
{targetRect && (
<svg style={{ position: "fixed", inset: 0, width: "100%", height: "100%", pointerEvents: "none" }}>
<defs>
<mask id="my-mask">
<rect width="100%" height="100%" fill="white" />
<rect
x={targetRect.x - padding} y={targetRect.y - padding}
width={targetRect.width + padding * 2}
height={targetRect.height + padding * 2}
rx={radius}
fill="black"
/>
</mask>
</defs>
<rect width="100%" height="100%" fill="rgba(0,0,0,0.6)" mask="url(#my-mask)" />
</svg>
)}
<div {...spotlightProps}>
<p>Custom content</p>
<button onClick={close}>Close</button>
</div>
</div>,
document.body
)}
</>
);
} SpotlightStep type #
interface SpotlightStep {
/** Ref or CSS selector of the element to highlight */
target: React.RefObject<Element | null> | string;
/** Padding around the target in px — overrides root padding */
padding?: number;
/** Cutout border-radius in px — overrides root radius */
radius?: number;
/** Content panel placement — overrides root placement */
placement?: SpotlightPlacement;
/** Content rendered in the panel for this step */
content?: React.ReactNode;
} API #
| Prop | Type | Default | Description |
|---|---|---|---|
| target | RefObject<Element> | string | — | Element to highlight. Required. |
| open | boolean | — | Controlled open state. |
| defaultOpen | boolean | false | Uncontrolled initial open state. |
| onOpenChange | (open: boolean) => void | — | Called when open state changes. |
| padding | number | 8 | Extra space around the target cutout in px. |
| radius | number | 8 | Border radius of the cutout in px. |
| placement | SpotlightPlacement | "bottom" | Content panel placement relative to target. |
| overlayColor | string | "rgba(0,0,0,0.6)" | Overlay fill color. |
| backdropBlur | number | 0 | Backdrop blur in px — applies backdrop-filter: blur(). |
| pulse | boolean | false | Show a pulsing ring around the cutout border. |
| closeOnOverlayClick | boolean | true | Close when clicking the overlay background. |
| closeOnEscape | boolean | true | Close on Escape key. |
| scrollIntoView | boolean | true | Scroll target into view on open and on step change. |
| children | ReactNode | — | Tooltip content (ignored when steps is set — use step content instead). |
| trigger | ReactNode | — | Renders a toggle wrapper — clicking it opens/closes the spotlight. |
| steps | SpotlightStep[] | — | Multi-step tour steps with built-in nav. |
| step | number | — | Controlled active step index. |
| defaultStep | number | 0 | Uncontrolled initial step index. |
| onStepChange | (step: number) => void | — | Called when the active step changes. |
| className | string | — | Class added to the overlay root element. |
| style | CSSProperties | — | Inline style on the overlay root element. |
useSpotlight return values #
| Name | Type | Description |
|---|---|---|
| isOpen | boolean | Whether the overlay is open. |
| open | () => void | Open the spotlight. |
| close | () => void | Close the spotlight. |
| toggle | () => void | Toggle open / closed. |
| targetRect | TargetRect | null | Live measured bounding rect of the active target: { x, y, width, height }. |
| padding | number | Resolved padding for the current step. |
| radius | number | Resolved cutout radius for the current step. |
| overlayProps | object | Spread onto the overlay element — role, aria-modal, aria-label, data-open, onClick, onKeyDown, style, tabIndex. |
| spotlightProps | object | Spread onto the content container — computed placement style. |
| step | number | Active step index (0 in single-step mode). |
| totalSteps | number | Total steps (0 when steps is not set). |
| nextStep | () => void | Advance to next step — clamped at last step. |
| prevStep | () => void | Go back one step — clamped at first step. |
| goToStep | (n: number) => void | Jump to any step index. |
CSS variables #
:root {
--rspot-overlay-color: rgba(0, 0, 0, 0.6);
--rspot-backdrop-blur: 0px;
--rspot-content-bg: #ffffff;
--rspot-content-fg: #18181b;
--rspot-content-border: #e4e4e7;
--rspot-content-shadow: 0 8px 32px rgba(0,0,0,0.18), 0 2px 8px rgba(0,0,0,0.1);
--rspot-content-radius: 12px;
--rspot-duration-in: 200ms;
--rspot-duration-out: 150ms;
--rspot-ease: cubic-bezier(0.32, 0.72, 0, 1);
}
Edit this page on GitHub