# Liquid Cursor

> Inside its card the pointer becomes a drop of liquid with a short trail; hover a link or button and the drop pours itself into that shape, then pinches free when you move on.

- Page: https://devl.dev/motion/liquid-cursor
- Kind: motion · Liquid
- Install: `npx shadcn@latest add https://devl.dev/r/liquid-cursor.json`
- Raw source: https://devl.dev/raw/liquid-cursor.tsx
- npm: motion
- License: MIT. The coss ui components it uses (https://coss.com/ui) are AGPL-3.0.

## Usage

```tsx
"use client";

import { ArrowUpRightIcon } from "lucide-react";
import { useState } from "react";
import { LiquidCursor, liquidTarget } from "@/components/liquid-cursor";

const nav = ["Work", "Studio", "Journal"];
const services = ["Brand", "Product", "Motion", "Web"];

const target =
  "relative cursor-pointer select-none outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-ring data-[liquid-active]:text-brand-foreground! data-[liquid-active]:delay-75";

export default function LiquidCursorDemo() {
  const [picked, setPicked] = useState<string[]>(["Product"]);

  return (
    <LiquidCursor className="flex w-full max-w-md flex-col gap-10 rounded-2xl border bg-card p-5 shadow-xs/5">
      <header className="flex items-center justify-between">
        <span className="font-medium text-sm">Fieldnote</span>
        <nav aria-label="Studio" className="-mr-1 flex items-center gap-0.5">
          {nav.map((item) => (
            <a
              className={`${target} rounded-full px-3 py-1.5 text-muted-foreground text-sm hover:text-foreground`}
              href={`#${item.toLowerCase()}`}
              key={item}
              onClick={(e) => e.preventDefault()}
              {...liquidTarget}
            >
              {item}
            </a>
          ))}
        </nav>
      </header>

      <div className="flex flex-col gap-2">
        <h3 className="text-balance font-heading font-medium text-3xl tracking-tight">We make quiet software for loud ideas.</h3>
        <p className="text-pretty text-muted-foreground text-sm">A two-person studio in Lisbon. Booking projects from March.</p>
      </div>

      <div className="flex flex-col gap-3">
        <span className="text-muted-foreground text-xs">What do you need?</span>
        <div className="flex items-center justify-between gap-3">
          <div className="flex flex-wrap gap-1.5">
            {services.map((s) => {
              const on = picked.includes(s);
              return (
                <button
                  aria-pressed={on}
                  className={`${target} h-8 rounded-full border px-3 font-medium text-muted-foreground text-xs aria-pressed:border-foreground/40 aria-pressed:text-foreground data-[liquid-active]:border-transparent`}
                  key={s}
                  onClick={() => setPicked((p) => (on ? p.filter((x) => x !== s) : [...p, s]))}
                  type="button"
                  {...liquidTarget}
                >
                  {s}
                </button>
              );
            })}
          </div>
          <button
            aria-label="Start a project"
            className={`${target} grid size-10 shrink-0 place-items-center rounded-full border`}
            type="button"
            {...liquidTarget}
          >
            <ArrowUpRightIcon className="size-4" />
          </button>
        </div>
      </div>
    </LiquidCursor>
  );
}
```

## Props

| Prop | Type | Default |
| --- | --- | --- |
| `goo`? | `number` | `6` |

Also accepts: `ComponentProps<"div">`.

## Source: components/liquid-cursor.tsx

```tsx
"use client";

import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react";
import { type ComponentProps, type FocusEvent, type PointerEvent, useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/utils";

function useReduce() {
  const reduce = useReducedMotion();
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  return mounted && !!reduce;
}

export const liquidTarget = { "data-liquid-target": "" } as const;

const HEAD = 22;
const TAIL = [
  { size: 19, rate: 30, gap: 9 },
  { size: 16, rate: 26, gap: 8 },
  { size: 13, rate: 22, gap: 7 },
  { size: 10, rate: 18, gap: 6 },
];
const headSpring = { stiffness: 520, damping: 38, mass: 0.7 };

export interface LiquidCursorProps extends ComponentProps<"div"> {
  goo?: number;
}

export function LiquidCursor({ goo = 6, className, children, ...props }: LiquidCursorProps) {
  const filterId = `liquid-cursor-${useId().replace(/:/g, "")}`;
  const reduce = useReduce();
  const ref = useRef<HTMLDivElement>(null);
  const tailRefs = useRef<(HTMLSpanElement | null)[]>([]);
  const activeEl = useRef<HTMLElement | null>(null);
  const pointer = useRef({ x: 0, y: 0 });
  const tail = useRef(TAIL.map(() => ({ x: 0, y: 0 })));
  const raf = useRef(0);
  const [visible, setVisibleState] = useState(false);
  const [mouse, setMouseState] = useState(false);
  const live = useRef({ visible: false, mouse: false });
  const setVisible = (v: boolean) => {
    live.current.visible = v;
    setVisibleState(v);
  };
  const setMouse = (v: boolean) => {
    live.current.mouse = v;
    setMouseState(v);
  };

  const tx = useMotionValue(0);
  const ty = useMotionValue(0);
  const tw = useMotionValue(HEAD);
  const th = useMotionValue(HEAD);
  const tr = useMotionValue(HEAD / 2);
  const press = useMotionValue(1);
  const cx = useSpring(tx, headSpring);
  const cy = useSpring(ty, headSpring);
  const w = useSpring(tw, headSpring);
  const h = useSpring(th, headSpring);
  const r = useSpring(tr, headSpring);
  const scale = useSpring(press, { stiffness: 700, damping: 30 });
  const left = useTransform(() => cx.get() - w.get() / 2);
  const top = useTransform(() => cy.get() - h.get() / 2);

  const paint = () => {
    tail.current.forEach((p, i) => {
      const el = tailRefs.current[i];
      if (el) el.style.transform = `translate(${p.x}px, ${p.y}px)`;
    });
  };

  const run = () => {
    if (raf.current) return;
    let last = performance.now();
    const tick = (now: number) => {
      const dt = Math.min((now - last) / 1000, 1 / 30);
      last = now;
      let moving = false;
      let lead = { x: cx.get(), y: cy.get() };
      tail.current.forEach((p, i) => {
        const { rate, gap } = TAIL[i];
        const k = 1 - Math.exp(-rate * dt);
        p.x += (lead.x - p.x) * k;
        p.y += (lead.y - p.y) * k;
        const dx = p.x - lead.x;
        const dy = p.y - lead.y;
        const d = Math.hypot(dx, dy);
        if (d > gap) {
          p.x = lead.x + (dx / d) * gap;
          p.y = lead.y + (dy / d) * gap;
        }
        if (d > 0.1) moving = true;
        lead = p;
      });
      paint();
      raf.current = moving ? requestAnimationFrame(tick) : 0;
    };
    raf.current = requestAnimationFrame(tick);
  };

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

  const setActive = (el: HTMLElement | null) => {
    if (activeEl.current === el) return;
    activeEl.current?.removeAttribute("data-liquid-active");
    el?.setAttribute("data-liquid-active", "");
    activeEl.current = el;
  };

  useEffect(() => () => setActive(null), []);

  const local = (x: number, y: number) => {
    const a = ref.current?.getBoundingClientRect();
    return a ? { x: x - a.left, y: y - a.top } : { x: 0, y: 0 };
  };

  const shapeTo = (el: HTMLElement | null) => {
    if (el) {
      const b = el.getBoundingClientRect();
      const c = local(b.left + b.width / 2, b.top + b.height / 2);
      tx.set(c.x);
      ty.set(c.y);
      tw.set(b.width);
      th.set(b.height);
      tr.set(Math.min(Number.parseFloat(getComputedStyle(el).borderTopLeftRadius) || 0, b.height / 2));
    } else {
      tx.set(pointer.current.x);
      ty.set(pointer.current.y);
      tw.set(HEAD);
      th.set(HEAD);
      tr.set(HEAD / 2);
    }
    setActive(el);
  };

  const placeAll = (x: number, y: number) => {
    pointer.current = { x, y };
    for (const p of tail.current) Object.assign(p, { x, y });
    paint();
    tx.jump(x);
    ty.jump(y);
    cx.jump(x);
    cy.jump(y);
  };

  const targetOf = (node: EventTarget | null) => {
    const el = node instanceof Element ? node.closest<HTMLElement>("[data-liquid-target]") : null;
    return el && ref.current?.contains(el) ? el : null;
  };

  const enter = (event: PointerEvent<HTMLDivElement>) => {
    props.onPointerEnter?.(event);
    if (reduce || event.pointerType !== "mouse") return;
    const p = local(event.clientX, event.clientY);
    placeAll(p.x, p.y);
    setMouse(true);
    setVisible(true);
  };

  const move = (event: PointerEvent<HTMLDivElement>) => {
    props.onPointerMove?.(event);
    if (reduce || event.pointerType !== "mouse") return;
    pointer.current = local(event.clientX, event.clientY);
    shapeTo(targetOf(event.target));
    run();
  };

  const leave = (event: PointerEvent<HTMLDivElement>) => {
    props.onPointerLeave?.(event);
    setMouse(false);
    setVisible(false);
    setActive(null);
  };

  const focus = (event: FocusEvent<HTMLDivElement>) => {
    props.onFocus?.(event);
    const el = targetOf(event.target);
    if (reduce || live.current.mouse || !el || !el.matches(":focus-visible")) return;
    const b = el.getBoundingClientRect();
    const c = local(b.left + b.width / 2, b.top + b.height / 2);
    if (live.current.visible) pointer.current = c;
    else placeAll(c.x, c.y);
    shapeTo(el);
    run();
    setVisible(true);
  };

  const blur = (event: FocusEvent<HTMLDivElement>) => {
    props.onBlur?.(event);
    if (live.current.mouse || ref.current?.contains(event.relatedTarget as Node | null)) return;
    setVisible(false);
    setActive(null);
  };

  return (
    <div
      {...props}
      className={cn("relative isolate", mouse && !reduce && "cursor-none [&_*]:cursor-none", className)}
      onBlur={blur}
      onFocus={focus}
      onPointerDown={(e) => {
        props.onPointerDown?.(e);
        if (e.pointerType === "mouse") press.set(0.92);
      }}
      onPointerEnter={enter}
      onPointerLeave={leave}
      onPointerMove={move}
      onPointerUp={(e) => {
        props.onPointerUp?.(e);
        press.set(1);
      }}
      ref={ref}
    >
      {!reduce && (
        <motion.div
          animate={{ opacity: visible ? 1 : 0 }}
          aria-hidden
          className="pointer-events-none absolute inset-0 -z-10 overflow-hidden rounded-[inherit]"
          initial={false}
          transition={{ duration: visible ? 0.12 : 0.2, ease: "easeOut" }}
        >
          <svg className="absolute size-0">
            <filter colorInterpolationFilters="sRGB" height="100%" id={filterId} width="100%" x="0" y="0">
              <feGaussianBlur in="SourceGraphic" result="blur" stdDeviation={goo} />
              <feColorMatrix in="blur" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 22 -9" />
            </filter>
          </svg>
          <div className="absolute inset-0" style={{ filter: `url(#${filterId})` }}>
            {TAIL.map((t, i) => (
              <span
                className="absolute top-0 left-0 rounded-full bg-brand"
                key={t.size}
                ref={(el) => {
                  tailRefs.current[i] = el;
                }}
                style={{ width: t.size, height: t.size, marginLeft: -t.size / 2, marginTop: -t.size / 2 }}
              />
            ))}
            <motion.span
              className="absolute top-0 left-0 bg-brand"
              style={{ x: left, y: top, width: w, height: h, borderRadius: r, scale }}
            />
          </div>
        </motion.div>
      )}
      {children}
    </div>
  );
}
```
