Skip to content
Corsair UI
Browse the docs

CreativeNo. 86 of 109

Hover Reveal

A second layer seen through a soft circle that follows the pointer.

Installation

pnpm dlx shadcn@latest add @corsair-ui/hover-reveal

No setup needed: the CLI finds @corsair-ui in the shadcn registry directory. The files land in your project as source: edit them like your own code. More in Installation.

Also adds
utils
npm
clsxtailwind-merge

Examples

Colour under the outline

Move the pointer over it.

Corsair

Hard edge

softness={0}.

The map is blank until you pass a lantern over it. Move the pointer across the page.

API

PropTypeDefault
reveal

The layer seen through the circle.

ReactNode—
radius

In px.

number120
softness

0 for a hard edge, 1 fades from the centre.

number0.4
revealClassName

Classes for the revealed layer.

string—

Accessibility

  • The revealed layer is hidden from screen readers and inert, so children carry the meaning.
  • Touch screens see the base only. The circle opens at once with prefers-reduced-motion.

Compatibility

Tailwind CSS
3.4 and 4, both checked in CI
React
19
Rendering
Client component ("use client")
Motion
Respects prefers-reduced-motion

Source

1 file, installed as source you own
hover-reveal.tsxView on GitHub
"use client";

import {
  useEffect,
  useMemo,
  useRef,
  useState,
  type ComponentProps,
  type CSSProperties,
  type PointerEvent,
  type ReactNode,
  type Ref,
} from "react";
import { cn } from "@/registry/default/lib/utils";

function mergeRefs<T>(...refs: (Ref<T> | undefined)[]) {
  return (node: T | null) => {
    for (const ref of refs) {
      if (typeof ref === "function") ref(node);
      else if (ref) ref.current = node;
    }
  };
}

function prefersReducedMotion() {
  return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
}

// How quickly the circle grows and shrinks: the time constant of an ease-out, in ms.
const EASE_MS = 90;

interface HoverRevealProps extends ComponentProps<"div"> {
  /** The layer shown through the circle. Decorative: it is hidden from screen readers. */
  reveal: ReactNode;
  /** Radius of the circle, in px. */
  radius?: number;
  /** From 0 to 1: 0 gives a crisp edge, 1 fades from the centre out. */
  softness?: number;
  /** Classes for the revealed layer. */
  revealClassName?: string;
}

/**
 * A second layer seen through a soft circle that follows the pointer: a
 * photo under a sketch, colour under greyscale, the answer under the
 * question. The circle opens when the pointer comes in and closes when it
 * leaves; it moves by writing CSS variables once per frame, so nothing
 * re-renders. Touch screens only see the base layer. The revealed layer is
 * hidden from screen readers and cannot take focus or clicks, so the base
 * (`children`) has to carry the meaning. With `prefers-reduced-motion` the
 * circle opens and closes at once.
 *
 * @example
 * <HoverReveal
 *   className="aspect-video rounded-xl"
 *   reveal={<img src="/chart-colour.jpg" alt="" className="size-full object-cover" />}
 * >
 *   <img src="/chart-sketch.jpg" alt="A sea chart of the bay" className="size-full object-cover" />
 * </HoverReveal>
 */
function HoverReveal({
  reveal,
  radius = 120,
  softness = 0.4,
  revealClassName,
  className,
  children,
  onPointerEnter,
  onPointerMove,
  onPointerLeave,
  ref,
  ...props
}: HoverRevealProps) {
  const [active, setActive] = useState(false);
  const root = useRef<HTMLDivElement>(null);
  const mergedRef = useMemo(() => mergeRefs(ref, root), [ref]);
  const frame = useRef(0);
  const motion = useRef({ x: 0, y: 0, r: 0, target: 0, last: 0 });

  useEffect(() => () => cancelAnimationFrame(frame.current), []);

  // Grows or shrinks the circle toward its target, and writes the position.
  const tick = (now: number) => {
    frame.current = 0;
    const node = root.current;
    if (!node) return;
    const state = motion.current;
    const elapsed = state.last ? now - state.last : 16;
    state.last = now;
    state.r += (state.target - state.r) * (1 - Math.exp(-elapsed / EASE_MS));
    if (Math.abs(state.target - state.r) < 0.5) state.r = state.target;
    node.style.setProperty("--hover-reveal-x", `${state.x}px`);
    node.style.setProperty("--hover-reveal-y", `${state.y}px`);
    node.style.setProperty("--hover-reveal-r", `${state.r}px`);
    if (state.r !== state.target) frame.current = requestAnimationFrame(tick);
    else state.last = 0;
  };

  const schedule = () => {
    if (!frame.current) frame.current = requestAnimationFrame(tick);
  };

  const track = (event: PointerEvent<HTMLDivElement>) => {
    const bounds = event.currentTarget.getBoundingClientRect();
    motion.current.x = event.clientX - bounds.left;
    motion.current.y = event.clientY - bounds.top;
  };

  const handlePointerEnter = (event: PointerEvent<HTMLDivElement>) => {
    onPointerEnter?.(event);
    if (event.pointerType === "touch") return;
    track(event);
    motion.current.target = radius;
    if (prefersReducedMotion()) motion.current.r = radius;
    setActive(true);
    schedule();
  };

  const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
    onPointerMove?.(event);
    if (event.pointerType === "touch") return;
    track(event);
    // A pointer that was already inside when the page loaded never "entered".
    if (!active) {
      motion.current.target = radius;
      if (prefersReducedMotion()) motion.current.r = radius;
      setActive(true);
    }
    schedule();
  };

  const handlePointerLeave = (event: PointerEvent<HTMLDivElement>) => {
    onPointerLeave?.(event);
    if (event.pointerType === "touch") return;
    motion.current.target = 0;
    if (prefersReducedMotion()) motion.current.r = 0;
    setActive(false);
    schedule();
  };

  const edge = Math.round((1 - Math.min(Math.max(softness, 0), 1)) * 100);
  const mask = `radial-gradient(circle var(--hover-reveal-r, 0px) at var(--hover-reveal-x, 50%) var(--hover-reveal-y, 50%), #000 ${edge}%, transparent 100%)`;

  return (
    <div
      ref={mergedRef}
      data-slot="hover-reveal"
      data-state={active ? "active" : "idle"}
      className={cn("relative isolate overflow-hidden", className)}
      onPointerEnter={handlePointerEnter}
      onPointerMove={handlePointerMove}
      onPointerLeave={handlePointerLeave}
      {...props}
    >
      {children}
      <div
        aria-hidden="true"
        inert
        data-slot="hover-reveal-layer"
        className={cn("pointer-events-none absolute inset-0", revealClassName)}
        style={{ maskImage: mask, WebkitMaskImage: mask } as CSSProperties}
      >
        {reveal}
      </div>
    </div>
  );
}

export { HoverReveal, type HoverRevealProps };