hover to morph · click to burst

Fluid Cursor

Fluid Cursor

A custom cursor: a dot rides the pointer while a trailing ring warps with its velocity and morphs on hover into a hand, arrow, text or view.

A free, copy-paste mouse effects component for React, Next.js — built with Tailwind CSS, installable with the shadcn CLI.

Configuration

Tweak these live from theConfigurecontrol in the preview.

effectring · dot · blob · spotlight · arrow — default "arrow".
arrowShaperounded · sharp — default "rounded".
colorcolour — default #3b82f6.
clickEffectconfetti · ripple · spark · none — default "confetti".
size20–80px — default 40px.
lag0.2–1.4 — default 0.7.
warp0–1 — default 0.6.
animatedon / off — default off.
imageon / off — default off.
magneticon / off — default on.
blendon / off — default off.

Interaction Type

  • The ring trails the pointer with spring-lag
  • It warps — stretching along its direction of travel
  • Magnetically locks onto buttons and links

Dependency

This component uses motion for its animation. Install it, then drop the file in.

npm install motion

Point the CLI here once

A namespace is a local alias, so the CLI has to be told what @harshdev-ui resolves to. One command, once per project:

npx shadcn registry add "@harshdev-ui=https://ui.harshpandav.dev/r/{name}.json"

After that every component installs by name. To skip it entirely, pass the URL straight to the CLI — shadcn add https://ui.harshpandav.dev/r/fluid-cursor.json — or copy the file from Source code below.

[ shadcn CLI 3.0 registry ]…or add the component in one command

Install via CLI

pnpm dlx shadcn add @harshdev-ui/fluid-cursor

How to use

Mount FluidCursor once at the root of your app for a site-wide custom cursor, or pass a containerRef to scope it to a single section (which is how the preview above keeps it inside its box). It replaces the OS pointer with a dot that rides the cursor and a ring that trails behind and warps from its own velocity.

Any a, button or [data-cursor] element triggers the hover state — the ring grows, magnetically locks onto the element, and can show a label via data-cursor-label. Turn on blend to have it invert over whatever is behind it. It disables itself on touch / coarse-pointer devices and respects prefers-reduced-motion, so there's nothing to guard for.

Site-wide (mount once)

import { FluidCursor } from "@/components/harsh-ui/fluid-cursor";

export default function RootLayout({ children }) {
  return (
    <body>
      {children}
      <FluidCursor />
    </body>
  );
}

Scoped to a section

Pass a containerRef and the cursor only takes over inside that element — perfect for a hero or a single feature.

const ref = useRef<HTMLDivElement>(null);

<section ref={ref} className="relative">
  <FluidCursor containerRef={ref} />
  {/* ... */}
</section>

Effects + tuning

<FluidCursor
  effect="arrow"        // "ring" | "dot" | "blob" | "spotlight" | "arrow"
  arrowShape="rounded"  // "rounded" | "sharp"
  size={48}
  lag={0.9}             // higher = laggier trail
  warp={0.8}            // higher = more velocity stretch
  blend                 // invert over any background
/>

Per-element morphs

data-cursor changes what the cursor becomes on hover: "hand" (1-finger pointer), "arrow", "text", "view", "grab", "plus" or "link". A bare data-cursor just grows/locks the ring, and data-cursor-label shows text.

<a data-cursor="hand">A link — points a finger</a>
<div data-cursor="view" data-cursor-label="Open">Gallery</div>
<input data-cursor="text" />
<button data-cursor data-cursor-label="Play">Grow + label</button>

Colour · click bursts · animated · image

Recolour it, pop a lightweight burst on every click (ripple / confetti / spark), add a spinning dashed halo, or swap the whole thing for an image.

<FluidCursor
  color="#a855f7"
  clickEffect="confetti"   // "ripple" | "confetti" | "spark" | "none"
  animated                 // spinning dashed halo + gentle pulse
/>

// …or drop an image inside the ring:
<FluidCursor image="/avatar.png" size={44} />

Demo

import { FluidCursorDemo } from "@/components/demos/fluid-cursor-demo";

const Demo = () => (
  <div className="h-screen w-full">
    <FluidCursorDemo />
  </div>
);

Props

effect"ring" (dot + outlined ring, default), "dot" (solid), "blob" (soft filled), "spotlight" (radial glow), or "arrow" (a pointer arrow).
arrowShapeCorner treatment for the arrow effect: "rounded" (default) or "sharp".
data-cursor (on your elements)Per-element morph: "hand", "arrow", "text", "view", "grab", "plus", "link" — or bare to grow/lock the ring. Pair with data-cursor-label to show text.
size / dotSizeRing diameter (default 40) and leading-dot diameter (default 7), in px.
colorCursor colour (default "#ffffff"). Works with blend — the chosen colour is what gets inverted (white gives the classic invert, a hue gives a tinted one).
clickEffect"ripple" (default), "confetti", "spark" or "none" — a lightweight burst spawned on each click.
animatedAdd a continuously spinning dashed halo and a gentle pulse. Default false.
imageFill the cursor with an image — a photo inside the shape that warps with it (works with any effect; keeps the ring's frame). Hover morphs still override it.
lagHow far the ring trails the pointer — higher is laggier. Default 0.7.
warpVelocity-driven stretch (0 = rigid, ~1 = very fluid). Default 0.6.
blendmix-blend-difference so the cursor inverts over any background. Default false.
magneticLock the ring onto hovered targets instead of just growing. Default true.
hideSystemCursorHide the OS cursor (and child cursors) within the scope. Default true.
hoverSelectorWhich elements trigger the hover state. Default "a, button, [data-cursor]".
containerRefScope the cursor to this element instead of the whole window.
autoPlaySelf-drive a phantom pointer — used for previews with no real cursor.

Notes

  • Built on Motion (useMotionValue / useSpring / useVelocity) — the same velocity-warp technique as Motion Reel. No GSAP.
  • Per-element morphs (hand / arrow / text / view / grab / plus / link) cross-fade in on hover; the geometric shapes warp with velocity while the icon shapes stay steady for legibility.
  • Optimised for smoothness: pointer moves only set motion values (no React re-render), and a lastTarget guard means moving within an element never re-renders either — hover state changes only on enter/leave.
  • The ring is an ellipse rotated to its direction of travel; because an ellipse is 180°-symmetric and the stretch fades to a circle at rest, the atan2 angle wrap is never visible.
  • Rendered through a portal — to document.body when global, or into the container when scoped — so it never gets clipped by an ancestor's overflow.
  • The native cursor is hidden via a .hui-cursor-none class that also covers children, so buttons and links don't reveal the OS pointer.
  • Disables itself on (hover: none) / (pointer: coarse) devices and drops the warp under prefers-reduced-motion.
  • Click bursts are a handful of Motion divs that remove themselves after ~0.8s (max six on screen), and they render outside the blend layer so confetti keeps its true colours.
  • In a grid card there's no pointer, so autoPlay drives a drifting phantom pointer and pops an occasional burst to keep it alive.

Source code

The complete fluid-cursor.tsx — toggle TSX / JSX to copy it in either language.

Source code
"use client";

import {
  useCallback,
  useEffect,
  useRef,
  useState,
  type ReactNode,
  type RefObject,
} from "react";
import { createPortal } from "react-dom";
import {
  AnimatePresence,
  motion,
  useMotionValue,
  useReducedMotion,
  useSpring,
  useTransform,
  useVelocity,
} from "motion/react";
import {
  ArrowUpRight,
  Eye,
  Grab,
  Plus,
  Pointer,
  TextCursor,
} from "lucide-react";

/**
 * Harsh Dev UI · Developed by Harsh Pandav
 * https://harshpandav.dev · hello@harshpandav.dev
 *
 * FluidCursor
 * A pointer replacement: a dot rides the cursor while a ring trails behind with
 * spring-lag and WARPS — it stretches along its direction of travel from its own
 * velocity. Any element can change what the cursor *becomes* on hover through a
 * `data-cursor` value (e.g. `data-cursor="hand"` → a pointing-finger cursor),
 * and it can grow/lock magnetically and show a `data-cursor-label`.
 *
 * Mount once at your app root for a site-wide cursor, or pass `containerRef` to
 * scope it to a single box (used by the docs demo). Zero dependencies beyond
 * React + Motion — no GSAP. Auto-disables on touch / coarse pointers and honours
 * prefers-reduced-motion.
 */

export type FluidCursorShape =
  | "ring"
  | "dot"
  | "blob"
  | "spotlight"
  | "arrow"
  | "hand"
  | "text"
  | "view"
  | "grab"
  | "plus"
  | "link";
export type FluidCursorEffect = FluidCursorShape;
export type FluidCursorArrow = "sharp" | "rounded";
export type FluidCursorClick = "none" | "ripple" | "confetti" | "spark";

export type FluidCursorProps = {
  effect?: FluidCursorEffect;
  arrowShape?: FluidCursorArrow;
  image?: string;
  clickEffect?: FluidCursorClick;
  animated?: boolean;
  size?: number;
  dotSize?: number;
  color?: string;
  lag?: number;
  warp?: number;
  blend?: boolean;
  magnetic?: boolean;
  hideSystemCursor?: boolean;
  hoverSelector?: string;
  containerRef?: RefObject<HTMLElement | null>;
  autoPlay?: boolean;
  className?: string;
};

const GEOMETRIC = new Set<FluidCursorShape>(["ring", "dot", "blob", "spotlight"]);
const SHAPES = new Set<string>([
  "ring", "dot", "blob", "spotlight", "arrow",
  "hand", "text", "view", "grab", "plus", "link",
]);
const HOVER_SCALE = 2.2;

const ANCHOR: Partial<Record<FluidCursorShape, [number, number]>> = {
  arrow: [-10, -10],
  hand: [-40, -12],
};

type Hover = { grow?: number; label?: string; shape?: FluidCursorShape };

export function FluidCursor({
  effect = "ring",
  arrowShape = "rounded",
  image,
  clickEffect = "ripple",
  animated = false,
  size = 40,
  dotSize = 7,
  color = "#ffffff",
  lag = 0.7,
  warp = 0.6,
  blend = false,
  magnetic = true,
  hideSystemCursor = true,
  hoverSelector = "a, button, [data-cursor]",
  containerRef,
  autoPlay = false,
  className = "",
}: FluidCursorProps) {
  const reduce = useReducedMotion();
  const hoverCapable = useHoverCapable();
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);

  const scoped = !!containerRef;

  const x = useMotionValue(-200);
  const y = useMotionValue(-200);

  const stiffness = Math.round((reduce ? 600 : 170) / lag);
  const spring = { stiffness, damping: 22, mass: 0.6 };
  const rx = useSpring(x, spring);
  const ry = useSpring(y, spring);

  const vx = useVelocity(rx);
  const vy = useVelocity(ry);
  const eff = reduce ? 0 : warp;
  const speed = useTransform(() => Math.hypot(vx.get(), vy.get()));
  const angle = useTransform(() => (Math.atan2(vy.get(), vx.get()) * 180) / Math.PI);
  const stretch = useTransform(speed, [0, 1400], [1, 1 + eff], { clamp: true });
  const squash = useTransform(speed, [0, 1400], [1, Math.max(0.35, 1 - eff * 0.7)], {
    clamp: true,
  });

  const [visible, setVisible] = useState(false);
  const [pressed, setPressed] = useState(false);
  const [hover, setHover] = useState<Hover | null>(null);
  const magnet = useRef<{ cx: number; cy: number } | null>(null);
  const lastTarget = useRef<HTMLElement | null>(null);

  const [bursts, setBursts] = useState<Burst[]>([]);
  const burstId = useRef(0);
  const spawnBurst = useCallback(
    (px: number, py: number) => {
      if (clickEffect === "none") return;
      const id = ++burstId.current;
      setBursts((b) => [
        ...b.slice(-5),
        { id, x: px, y: py, kind: clickEffect, color, parts: makeParts(clickEffect, color) },
      ]);
      window.setTimeout(() => setBursts((b) => b.filter((x) => x.id !== id)), 820);
    },
    [clickEffect, color],
  );

  useEffect(() => {
    if (autoPlay || !hoverCapable) return;
    const el = scoped ? containerRef?.current : null;
    if (scoped && !el) return;

    const rectOf = () => (el ? el.getBoundingClientRect() : null);
    const toScope = (cx: number, cy: number): [number, number] => {
      const r = rectOf();
      return scoped && r ? [cx - r.left, cy - r.top] : [cx, cy];
    };

    const move = (e: PointerEvent) => {
      const [px, py] = toScope(e.clientX, e.clientY);
      const m = magnet.current;
      if (m) {
        x.set(m.cx + (px - m.cx) * 0.18);
        y.set(m.cy + (py - m.cy) * 0.18);
      } else {
        x.set(px);
        y.set(py);
      }
      if (!scoped) setVisible(true);
    };
    const enter = () => setVisible(true);
    const leave = () => {
      setVisible(false);
      setHover(null);
      magnet.current = null;
      lastTarget.current = null;
    };
    const down = (e: PointerEvent) => {
      setPressed(true);
      const [px, py] = toScope(e.clientX, e.clientY);
      spawnBurst(px, py);
    };
    const up = () => setPressed(false);

    const over = (e: PointerEvent) => {
      const target = (e.target as HTMLElement)?.closest?.(hoverSelector) as HTMLElement | null;
      if (target === lastTarget.current) return;
      lastTarget.current = target;

      if (!target) {
        setHover(null);
        magnet.current = null;
        return;
      }
      const raw = target.dataset.cursor ?? "";
      const label = target.dataset.cursorLabel;
      const shape = SHAPES.has(raw) ? (raw as FluidCursorShape) : undefined;

      if (shape) {
        magnet.current = null;
        setHover({ shape, label });
      } else if (magnetic) {
        const tr = target.getBoundingClientRect();
        const [cx, cy] = toScope(tr.left + tr.width / 2, tr.top + tr.height / 2);
        magnet.current = { cx, cy };
        x.set(cx);
        y.set(cy);
        setHover({ grow: (Math.max(tr.width, tr.height) + 22) / size, label });
      } else {
        magnet.current = null;
        setHover({ grow: HOVER_SCALE, label });
      }
    };

    const listenEl: HTMLElement | Window = scoped && el ? el : window;
    listenEl.addEventListener("pointermove", move as EventListener, { passive: true });
    listenEl.addEventListener("pointerover", over as EventListener, { passive: true });
    listenEl.addEventListener("pointerdown", down as EventListener);
    listenEl.addEventListener("pointerup", up);
    if (scoped && el) {
      el.addEventListener("pointerenter", enter);
      el.addEventListener("pointerleave", leave);
    } else {
      setVisible(true);
      document.addEventListener("pointerleave", leave);
      document.addEventListener("pointerenter", enter);
    }

    return () => {
      listenEl.removeEventListener("pointermove", move as EventListener);
      listenEl.removeEventListener("pointerover", over as EventListener);
      listenEl.removeEventListener("pointerdown", down as EventListener);
      listenEl.removeEventListener("pointerup", up);
      if (scoped && el) {
        el.removeEventListener("pointerenter", enter);
        el.removeEventListener("pointerleave", leave);
      } else {
        document.removeEventListener("pointerleave", leave);
        document.removeEventListener("pointerenter", enter);
      }
    };
  }, [autoPlay, hoverCapable, scoped, containerRef, hoverSelector, magnetic, size, x, y, spawnBurst]);

  useEffect(() => {
    if (!autoPlay) return;
    const el = containerRef?.current;
    if (!el) return;
    setVisible(true);
    let raf = 0;
    let t0: number | null = null;
    const loop = (t: number) => {
      if (t0 === null) t0 = t;
      const e = (t - t0) / 1000;
      const w = el.offsetWidth;
      const h = el.offsetHeight;
      x.set(w * (0.5 + 0.3 * Math.sin(e * 1.1)));
      y.set(h * (0.5 + 0.24 * Math.sin(e * 1.9 + 1)));
      raf = requestAnimationFrame(loop);
    };
    raf = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(raf);
  }, [autoPlay, containerRef, x, y]);

  useEffect(() => {
    if (!autoPlay || clickEffect === "none") return;
    const id = window.setInterval(() => spawnBurst(x.get(), y.get()), 1900);
    return () => window.clearInterval(id);
  }, [autoPlay, clickEffect, spawnBurst, x, y]);

  useEffect(() => {
    if (!hideSystemCursor || !hoverCapable || autoPlay) return;
    const el = scoped ? containerRef?.current : document.documentElement;
    if (!el) return;
    el.classList.add("hui-cursor-none");
    return () => el.classList.remove("hui-cursor-none");
  }, [hideSystemCursor, hoverCapable, autoPlay, scoped, containerRef]);

  if (!mounted || !hoverCapable) return null;
  const portalTarget = scoped ? containerRef?.current : document.body;
  if (!portalTarget) return null;

  const ink = color;
  const morph = hover?.shape;
  const shape: FluidCursorShape = morph ?? effect;
  const isGeo = GEOMETRIC.has(shape);
  const hasImage = !!image && !morph;
  // Magnetic grow swells the cursor to cover its target. That reads as a blob
  // for the geometric shapes, but scaling a glyph just makes a giant arrow, so
  // glyph cursors morph into a blob for the duration instead.
  const blob = !!hover?.grow && magnetic && !isGeo && !hasImage;
  const [ax, ay] = blob || hasImage ? [-50, -50] : ANCHOR[shape] ?? [-50, -50];
  const growScale = (hover?.grow ?? 1) * (pressed ? 0.82 : 1);
  const showDot = effect === "ring" && !morph && !hasImage;
  const dotOn = showDot && visible && !(hover?.grow && magnetic);
  const showHalo = animated && !reduce && (isGeo || hasImage);

  const pos = scoped ? "absolute" : "fixed";
  const overlay = (
    <>
      <div
        aria-hidden
        className={`pointer-events-none z-[9998] ${pos} inset-0 overflow-hidden ${className}`}
        style={{ mixBlendMode: blend ? "difference" : undefined }}
      >
        <motion.div
          style={{ x: rx, y: ry }}
          className="absolute left-0 top-0"
          animate={{ opacity: visible ? 1 : 0 }}
          transition={{ duration: 0.2 }}
        >
          <motion.div
            style={{ x: `${ax}%`, y: `${ay}%` }}
            animate={{ scale: growScale }}
            transition={{ type: "spring", stiffness: 320, damping: 26, mass: 0.5 }}
          >
            <motion.div
              className="relative grid place-items-center"
              animate={showHalo ? { scale: [1, 1.1, 1] } : { scale: 1 }}
              transition={
                showHalo
                  ? { duration: 2.4, repeat: Infinity, ease: "easeInOut" }
                  : { duration: 0 }
              }
            >
              {showHalo && (
                <motion.span
                  className="absolute rounded-full"
                  style={{
                    width: size * 1.7,
                    height: size * 1.7,
                    border: `1.5px dashed ${ink}`,
                    opacity: 0.5,
                  }}
                  animate={{ rotate: 360 }}
                  transition={{ duration: 6, repeat: Infinity, ease: "linear" }}
                />
              )}

              <AnimatePresence initial={false} mode="popLayout">
                <motion.div
                  key={blob ? "magnet" : hasImage ? "image" : shape}
                  initial={{ opacity: 0, scale: 0.4 }}
                  animate={{ opacity: 1, scale: 1 }}
                  exit={{ opacity: 0, scale: 0.4 }}
                  transition={{ duration: 0.16, ease: "easeOut" }}
                  className="grid place-items-center"
                >
                  {blob ? (
                    <div
                      className="rounded-full"
                      style={{
                        width: size,
                        height: size,
                        background: `color-mix(in srgb, ${ink} 16%, transparent)`,
                        border: `1.5px solid ${ink}`,
                      }}
                    />
                  ) : hasImage ? (
                    <motion.div
                      className="overflow-hidden rounded-full"
                      style={{
                        width: size,
                        height: size,
                        rotate: angle,
                        scaleX: stretch,
                        scaleY: squash,
                        border: effect === "ring" ? `2px solid ${ink}` : undefined,
                      }}
                    >
                      {/* eslint-disable-next-line @next/next/no-img-element */}
                      <img
                        src={image}
                        alt=""
                        draggable={false}
                        className="h-full w-full select-none object-cover"
                      />
                    </motion.div>
                  ) : isGeo ? (
                    <motion.div
                      style={{
                        width: size,
                        height: size,
                        rotate: angle,
                        scaleX: stretch,
                        scaleY: squash,
                        ...geoStyle(shape, ink),
                      }}
                    />
                  ) : (
                    <div style={{ color: ink, lineHeight: 0 }}>
                      {icon(shape, size, ink, arrowShape)}
                    </div>
                  )}
                  {hover?.label && (
                    <motion.span
                      className="absolute whitespace-nowrap text-[10px] font-semibold uppercase tracking-wide"
                      style={{ color: ink }}
                      // undo the grow, or the label swells with the blob
                      animate={{ scale: 1 / growScale }}
                      transition={{ type: "spring", stiffness: 320, damping: 26, mass: 0.5 }}
                    >
                      {hover.label}
                    </motion.span>
                  )}
                </motion.div>
              </AnimatePresence>
            </motion.div>
          </motion.div>
        </motion.div>

        {showDot && (
          <motion.div style={{ x, y }} className="absolute left-0 top-0">
            <motion.div
              className="-translate-x-1/2 -translate-y-1/2 rounded-full"
              style={{ width: dotSize, height: dotSize, background: ink }}
              animate={{ opacity: dotOn ? 1 : 0, scale: pressed ? 1.6 : 1 }}
              transition={{ duration: 0.18 }}
            />
          </motion.div>
        )}
      </div>

      <div
        aria-hidden
        className={`pointer-events-none z-[9999] ${pos} inset-0 overflow-hidden`}
      >
        <AnimatePresence>
          {bursts.map((b) => (
            <BurstView key={b.id} burst={b} />
          ))}
        </AnimatePresence>
      </div>
    </>
  );

  return createPortal(overlay, portalTarget);
}

function geoStyle(shape: FluidCursorShape, ink: string): React.CSSProperties {
  switch (shape) {
    case "dot":
      return { borderRadius: 999, background: ink };
    case "blob":
      return { borderRadius: 999, background: ink, opacity: 0.35, filter: "blur(6px)" };
    case "spotlight":
      return {
        borderRadius: 999,
        background: `radial-gradient(circle, ${ink} 0%, transparent 70%)`,
        opacity: 0.5,
        transform: "scale(2.6)",
      };
    default:
      return { borderRadius: 999, border: `2px solid ${ink}` };
  }
}

function icon(
  shape: FluidCursorShape,
  size: number,
  ink: string,
  arrowShape: FluidCursorArrow,
): ReactNode {
  const s = Math.round(size * 0.7);
  switch (shape) {
    case "arrow":
      return <Arrow size={size} ink={ink} rounded={arrowShape === "rounded"} />;
    case "hand":
      return <Pointer size={s} strokeWidth={2} fill="currentColor" fillOpacity={0.12} />;
    case "text":
      return <TextCursor size={s} strokeWidth={2} />;
    case "view":
      return <Eye size={s} strokeWidth={2} />;
    case "grab":
      return <Grab size={s} strokeWidth={2} />;
    case "plus":
      return <Plus size={s} strokeWidth={2.2} />;
    case "link":
      return <ArrowUpRight size={s} strokeWidth={2.2} />;
    default:
      return null;
  }
}

function Arrow({ size, ink, rounded }: { size: number; ink: string; rounded: boolean }) {
  return (
    <svg width={size} height={size} viewBox="0 0 20 20" style={{ overflow: "visible" }}>
      <path
        d="M2 2 L2 16 L6 12.2 L8.6 17.8 L11 16.7 L8.4 11.2 L14 11.2 Z"
        fill={ink}
        stroke={rounded ? ink : "none"}
        strokeWidth={rounded ? size * 0.11 : 0}
        strokeLinejoin="round"
        strokeLinecap="round"
      />
    </svg>
  );
}

type ClickParticle = {
  dx: number;
  dy: number;
  rot: number;
  color: string;
  size: number;
  round: boolean;
};
type Burst = {
  id: number;
  x: number;
  y: number;
  kind: FluidCursorClick;
  color: string;
  parts: ClickParticle[];
};

const CONFETTI = ["#3b82f6", "#f43f5e", "#eab308", "#22c55e", "#a855f7", "#f97316"];

function makeParts(kind: FluidCursorClick, color: string): ClickParticle[] {
  if (kind === "ripple" || kind === "none") return [];
  const palette = [color, ...CONFETTI];
  const spark = kind === "spark";
  const n = spark ? 9 : 14;
  return Array.from({ length: n }, (_, i) => {
    const a = (i / n) * Math.PI * 2 + Math.random() * 0.6;
    const dist = 32 + Math.random() * (spark ? 50 : 58);
    return {
      dx: Math.cos(a) * dist,
      dy: Math.sin(a) * dist,
      rot: Math.random() * 360,
      color: spark ? color : palette[i % palette.length],
      size: spark ? 2.5 : 4 + Math.random() * 5,
      round: spark ? true : Math.random() > 0.5,
    };
  });
}

function BurstView({ burst }: { burst: Burst }) {
  if (burst.kind === "ripple") {
    return (
      <motion.span
        className="absolute rounded-full"
        style={{
          left: burst.x,
          top: burst.y,
          width: 16,
          height: 16,
          marginLeft: -8,
          marginTop: -8,
          border: `2px solid ${burst.color}`,
        }}
        initial={{ scale: 0.3, opacity: 0.7 }}
        animate={{ scale: 4.5, opacity: 0 }}
        exit={{ opacity: 0 }}
        transition={{ duration: 0.6, ease: "easeOut" }}
      />
    );
  }
  const gravity = burst.kind === "confetti" ? 24 : 0;
  return (
    <div className="absolute" style={{ left: burst.x, top: burst.y }}>
      {burst.parts.map((p, i) => (
        <motion.span
          key={i}
          className="absolute"
          style={{
            width: p.size,
            height: p.size,
            background: p.color,
            borderRadius: p.round ? 999 : 2,
          }}
          initial={{ x: 0, y: 0, opacity: 1, rotate: 0 }}
          animate={{ x: p.dx, y: p.dy + gravity, opacity: 0, rotate: p.rot }}
          transition={{ duration: 0.7, ease: "easeOut" }}
        />
      ))}
    </div>
  );
}

function useHoverCapable() {
  const [ok, setOk] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
    const update = () => setOk(mq.matches);
    update();
    mq.addEventListener("change", update);
    return () => mq.removeEventListener("change", update);
  }, []);
  return ok;
}

Keep in mind

Some components here are recreations of the best interactions out there. I don't claim to be the original creator — this is my attempt to study, replicate, and often add a few extra features. I've tried to credit everyone; if I missed something, let me know.

Contact

Found a bug or an issue? Drop a mail — hello@harshpandav.dev

License & Usage

  • Free to use and modify in personal and commercial projects.
  • Attribution to Harsh Dev UI is appreciated but not required.
  • MIT licensed.

Command Palette

Search for a command to run...