Web InnoventixFreeCode

Blend Cursor

Original · free

mix-blend cursor

byWeb InnoventixReact + Tailwind
curblendcursors
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/cur-blend.json
cur-blend.tsx
"use client";

import { useId, useRef, useState } from "react";
import {
  motion,
  useMotionValue,
  useMotionValueEvent,
  useReducedMotion,
  useSpring,
} from "motion/react";

const BLENDS = [
  { value: "difference", label: "Difference" },
  { value: "exclusion", label: "Exclusion" },
  { value: "overlay", label: "Overlay" },
  { value: "hue", label: "Hue" },
] as const;
type Blend = (typeof BLENDS)[number]["value"];

const SHAPES = [
  { value: "filled", label: "Filled" },
  { value: "ring", label: "Ring" },
] as const;
type Shape = (typeof SHAPES)[number]["value"];

const WORD_TONE = ["text-white", "text-amber-200", "text-sky-200", "text-emerald-200"];

const BLEND_NOTE: Record<Blend, string> = {
  difference: "Subtracts colours — white flips whatever it crosses to its exact inverse.",
  exclusion: "Softer than difference; mid-greys mute rather than fully invert.",
  overlay: "Multiplies shadows and screens highlights for a stained-glass wash.",
  hue: "Keeps luminance, repaints the underlying colour with the disc's hue.",
};

export default function CurBlend() {
  const reduce = useReducedMotion();
  const stageRef = useRef<HTMLDivElement>(null);
  const xReadout = useRef<HTMLSpanElement>(null);
  const yReadout = useRef<HTMLSpanElement>(null);

  const blendName = useId();
  const shapeName = useId();
  const sizeId = useId();
  const trailId = useId();
  const switchLabelId = useId();

  const [enabled, setEnabled] = useState(true);
  const [blend, setBlend] = useState<Blend>("difference");
  const [shape, setShape] = useState<Shape>("filled");
  const [size, setSize] = useState(60);
  const [trail, setTrail] = useState(48);
  const [inside, setInside] = useState(false);

  const stiffness = Math.round(720 - trail * 6.2);

  const x = useMotionValue(0);
  const y = useMotionValue(0);
  const sx = useSpring(x, { stiffness, damping: 32, mass: 0.6 });
  const sy = useSpring(y, { stiffness, damping: 32, mass: 0.6 });
  const tx = useSpring(x, { stiffness: Math.round(stiffness * 0.45), damping: 26, mass: 1.2 });
  const ty = useSpring(y, { stiffness: Math.round(stiffness * 0.45), damping: 26, mass: 1.2 });

  const scaleTarget = useMotionValue(1);
  const scaleSpring = useSpring(scaleTarget, { stiffness: 260, damping: 18, mass: 0.5 });

  const dx = reduce ? x : sx;
  const dy = reduce ? y : sy;
  const dtx = reduce ? x : tx;
  const dty = reduce ? y : ty;
  const dScale = reduce ? scaleTarget : scaleSpring;

  useMotionValueEvent(x, "change", (v) => {
    if (xReadout.current) xReadout.current.textContent = String(Math.round(v));
  });
  useMotionValueEvent(y, "change", (v) => {
    if (yReadout.current) yReadout.current.textContent = String(Math.round(v));
  });

  const track = (clientX: number, clientY: number) => {
    const rect = stageRef.current?.getBoundingClientRect();
    if (!rect) return;
    x.set(clientX - rect.left);
    y.set(clientY - rect.top);
  };

  const pillBase =
    "inline-flex cursor-pointer items-center rounded-full border px-4 py-2 text-sm font-medium transition select-none " +
    "border-neutral-300 text-neutral-700 hover:border-neutral-400 " +
    "dark:border-neutral-700 dark:text-neutral-200 dark:hover:border-neutral-500 " +
    "peer-checked:border-transparent peer-checked:bg-indigo-600 peer-checked:text-white " +
    "peer-focus-visible:outline-none peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 " +
    "peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-neutral-50 " +
    "dark:peer-focus-visible:ring-offset-neutral-900";

  const sliderClass =
    "h-2 w-full cursor-pointer appearance-none rounded-full bg-neutral-200 accent-indigo-600 " +
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 " +
    "focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 " +
    "dark:bg-neutral-700 dark:accent-indigo-400 dark:focus-visible:ring-offset-neutral-900";

  const cardClass =
    "rounded-2xl border border-neutral-200 bg-neutral-50/80 p-5 " +
    "dark:border-neutral-800 dark:bg-neutral-900/60";

  return (
    <section className="relative w-full bg-white px-4 py-16 text-neutral-900 sm:px-6 sm:py-24 dark:bg-neutral-950 dark:text-neutral-50">
      <style>{`
        @keyframes curblend-pulse { 0%,100%{opacity:.35;transform:scale(1);} 50%{opacity:1;transform:scale(1.7);} }
        @keyframes curblend-drift { 0%{transform:translate3d(0,0,0);} 50%{transform:translate3d(0,-18px,0);} 100%{transform:translate3d(0,0,0);} }
        .curblend-pulse{animation:curblend-pulse 1.8s ease-in-out infinite;}
        .curblend-drift-a{animation:curblend-drift 9s ease-in-out infinite;}
        .curblend-drift-b{animation:curblend-drift 12s ease-in-out infinite reverse;}
        @media (prefers-reduced-motion: reduce){
          .curblend-pulse,.curblend-drift-a,.curblend-drift-b{animation:none !important;}
        }
      `}</style>

      <div className="mx-auto max-w-5xl">
        <header className="max-w-2xl">
          <p className="text-xs font-semibold uppercase tracking-[0.22em] text-indigo-600 dark:text-indigo-400">
            Cursors / mix-blend
          </p>
          <h2 className="mt-3 text-3xl font-semibold tracking-tight sm:text-4xl">
            Paint the screen with a blend cursor
          </h2>
          <p className="mt-4 text-base leading-relaxed text-neutral-600 dark:text-neutral-400">
            A pointer-driven disc that mixes with everything beneath it. Drag across the field
            below — the disc inverts, recolours, or washes each patch it touches, and grows when
            it meets a live target. Tune the blend, shape, size, and trail with real controls.
          </p>
        </header>

        <div
          ref={stageRef}
          role="group"
          aria-label="Blend cursor demonstration field"
          onPointerMove={(e) => {
            setInside(true);
            track(e.clientX, e.clientY);
          }}
          onPointerEnter={() => setInside(true)}
          onPointerLeave={() => setInside(false)}
          style={{ cursor: enabled ? "none" : "auto" }}
          className="relative mt-10 min-h-[360px] overflow-hidden rounded-3xl border border-black/10 bg-gradient-to-br from-indigo-600 via-violet-600 to-rose-600 p-6 shadow-2xl sm:min-h-[440px] sm:p-10 dark:border-white/10 dark:from-indigo-800 dark:via-violet-900 dark:to-rose-900"
        >
          <div
            aria-hidden
            className="curblend-drift-a pointer-events-none absolute -left-16 -top-10 h-64 w-64 rounded-full bg-amber-400/70 blur-3xl"
          />
          <div
            aria-hidden
            className="curblend-drift-b pointer-events-none absolute -bottom-20 right-4 h-72 w-72 rounded-full bg-emerald-400/60 blur-3xl"
          />
          <div
            aria-hidden
            className="curblend-drift-a pointer-events-none absolute right-1/3 top-1/2 h-48 w-48 rounded-full bg-sky-400/60 blur-3xl"
          />

          <div className="pointer-events-none absolute right-4 top-4 rounded-full bg-black/25 px-3 py-1 font-mono text-[11px] text-white/90 backdrop-blur-sm">
            x <span ref={xReadout}>0</span> · y <span ref={yReadout}>0</span>
          </div>

          <div className="relative z-10 flex h-full min-h-[300px] flex-col justify-between gap-8 sm:min-h-[360px]">
            <p className="max-w-md text-sm font-medium leading-relaxed text-white/90">
              Each word is a live target — hover to swell the disc, click to switch the blend mode.
            </p>

            <div className="flex flex-wrap items-baseline gap-x-6 gap-y-3">
              {BLENDS.map((b, i) => {
                const active = blend === b.value;
                return (
                  <button
                    key={b.value}
                    type="button"
                    aria-pressed={active}
                    onClick={() => setBlend(b.value)}
                    onPointerEnter={() => scaleTarget.set(2.4)}
                    onPointerLeave={() => scaleTarget.set(1)}
                    onFocus={() => scaleTarget.set(2.4)}
                    onBlur={() => scaleTarget.set(1)}
                    className={`rounded-lg px-1 text-4xl font-black leading-none tracking-tight transition-all sm:text-6xl ${
                      WORD_TONE[i]
                    } ${
                      active
                        ? "underline decoration-white/85 decoration-4 underline-offset-8"
                        : "opacity-90 hover:opacity-100"
                    } focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-transparent`}
                  >
                    {b.label}
                  </button>
                );
              })}
            </div>

            <div
              className={`flex items-center gap-2 self-start rounded-full bg-black/25 px-3 py-1.5 text-xs font-medium text-white/90 backdrop-blur-sm transition-opacity duration-300 ${
                inside && enabled ? "opacity-0" : "opacity-100"
              }`}
            >
              <span className="curblend-pulse inline-block h-2 w-2 rounded-full bg-white" />
              {enabled ? "Move your pointer across the field" : "Blend cursor is off"}
            </div>
          </div>

          {enabled && (
            <div
              aria-hidden
              className={`pointer-events-none absolute inset-0 z-20 transition-opacity duration-200 ${
                inside ? "opacity-100" : "opacity-0"
              }`}
            >
              <motion.div style={{ x: dtx, y: dty }} className="absolute left-0 top-0 will-change-transform">
                <div className="-translate-x-1/2 -translate-y-1/2">
                  <motion.div
                    style={{ width: size * 1.9, height: size * 1.9, mixBlendMode: blend }}
                    className="rounded-full border border-white/70"
                  />
                </div>
              </motion.div>

              <motion.div style={{ x: dx, y: dy }} className="absolute left-0 top-0 will-change-transform">
                <div className="-translate-x-1/2 -translate-y-1/2">
                  <motion.div
                    style={{ width: size, height: size, scale: dScale, mixBlendMode: blend }}
                    className={`rounded-full ${
                      shape === "ring" ? "border-[3px] border-white bg-transparent" : "bg-white"
                    }`}
                  />
                </div>
              </motion.div>
            </div>
          )}
        </div>

        <div className="mt-6 grid gap-4 md:grid-cols-2">
          <fieldset className={`${cardClass} md:col-span-2`}>
            <legend className="px-1 text-xs font-semibold uppercase tracking-[0.16em] text-neutral-500 dark:text-neutral-400">
              Blend mode
            </legend>
            <div className="mt-3 flex flex-wrap gap-2">
              {BLENDS.map((b) => (
                <label key={b.value}>
                  <input
                    type="radio"
                    name={blendName}
                    value={b.value}
                    checked={blend === b.value}
                    onChange={() => setBlend(b.value)}
                    className="peer sr-only"
                  />
                  <span className={pillBase}>{b.label}</span>
                </label>
              ))}
            </div>
            <p className="mt-3 text-sm leading-relaxed text-neutral-500 dark:text-neutral-400">
              {BLEND_NOTE[blend]}
            </p>
          </fieldset>

          <div className={cardClass}>
            <p className="text-xs font-semibold uppercase tracking-[0.16em] text-neutral-500 dark:text-neutral-400">
              Disc
            </p>

            <div className="mt-3 flex flex-wrap gap-2" role="radiogroup" aria-label="Disc shape">
              {SHAPES.map((s) => (
                <label key={s.value}>
                  <input
                    type="radio"
                    name={shapeName}
                    value={s.value}
                    checked={shape === s.value}
                    onChange={() => setShape(s.value)}
                    className="peer sr-only"
                  />
                  <span className={pillBase}>{s.label}</span>
                </label>
              ))}
            </div>

            <div className="mt-5">
              <label htmlFor={sizeId} className="flex items-center justify-between text-sm font-medium text-neutral-700 dark:text-neutral-200">
                <span>Diameter</span>
                <span className="font-mono text-xs text-neutral-500 dark:text-neutral-400">{size}px</span>
              </label>
              <input
                id={sizeId}
                type="range"
                min={16}
                max={120}
                step={2}
                value={size}
                onChange={(e) => setSize(Number(e.target.value))}
                aria-valuetext={`${size} pixels`}
                className={`mt-2 ${sliderClass}`}
              />
            </div>
          </div>

          <div className={cardClass}>
            <div className="flex items-center justify-between gap-4">
              <span id={switchLabelId} className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
                Blend cursor
              </span>
              <button
                type="button"
                role="switch"
                aria-checked={enabled}
                aria-labelledby={switchLabelId}
                onClick={() => setEnabled((v) => !v)}
                className={`relative inline-flex h-7 w-12 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-offset-neutral-900 ${
                  enabled ? "bg-indigo-600" : "bg-neutral-300 dark:bg-neutral-700"
                }`}
              >
                <span
                  className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${
                    enabled ? "translate-x-6" : "translate-x-1"
                  }`}
                />
              </button>
            </div>

            <div className="mt-5">
              <label htmlFor={trailId} className="flex items-center justify-between text-sm font-medium text-neutral-700 dark:text-neutral-200">
                <span>Trail</span>
                <span className="font-mono text-xs text-neutral-500 dark:text-neutral-400">{trail}%</span>
              </label>
              <input
                id={trailId}
                type="range"
                min={0}
                max={100}
                step={1}
                value={trail}
                onChange={(e) => setTrail(Number(e.target.value))}
                aria-valuetext={`${trail} percent lag`}
                className={`mt-2 ${sliderClass}`}
              />
              <p className="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
                Higher values loosen the spring so the disc drifts behind the pointer.
              </p>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →