Web InnoventixFreeCode

Dots Background

Original · free

dotted grid background

byWeb InnoventixReact + Tailwind
bgxdotsbackgrounds
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/bgx-dots.json
bgx-dots.tsx
"use client";

import {
  useId,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
  type PointerEvent,
} from "react";
import {
  motion,
  useMotionTemplate,
  useMotionValue,
  useReducedMotion,
  useSpring,
} from "motion/react";

type Accent = {
  id: string;
  label: string;
  dot: string;
  swatch: string;
};

const ACCENTS: readonly Accent[] = [
  { id: "indigo", label: "Indigo", dot: "#6366f1", swatch: "bg-indigo-500" },
  { id: "violet", label: "Violet", dot: "#8b5cf6", swatch: "bg-violet-500" },
  { id: "sky", label: "Sky", dot: "#0ea5e9", swatch: "bg-sky-500" },
  { id: "emerald", label: "Emerald", dot: "#10b981", swatch: "bg-emerald-500" },
  { id: "amber", label: "Amber", dot: "#f59e0b", swatch: "bg-amber-500" },
  { id: "rose", label: "Rose", dot: "#f43f5e", swatch: "bg-rose-500" },
] as const;

const EDGE_MASK =
  "radial-gradient(ellipse 115% 100% at 50% 46%, #000 52%, transparent 100%)";

function Switch({
  checked,
  onChange,
  label,
}: {
  checked: boolean;
  onChange: (next: boolean) => void;
  label: string;
}) {
  return (
    <button
      type="button"
      role="switch"
      aria-checked={checked}
      aria-label={label}
      onClick={() => onChange(!checked)}
      className={[
        "relative inline-flex h-6 w-11 shrink-0 items-center rounded-full",
        "outline-none transition-colors duration-200",
        "focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2",
        "focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950",
        checked
          ? "bg-indigo-500"
          : "bg-slate-300 dark:bg-slate-700",
      ].join(" ")}
    >
      <span
        className={[
          "inline-block h-4 w-4 rounded-full bg-white shadow-sm",
          "transition-transform duration-200",
          checked ? "translate-x-6" : "translate-x-1",
        ].join(" ")}
      />
    </button>
  );
}

export default function BgxDots() {
  const reduce = useReducedMotion();
  const sectionRef = useRef<HTMLElement | null>(null);
  const swatchRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const gapId = useId();
  const sizeId = useId();
  const spotId = useId();

  const [gap, setGap] = useState(26);
  const [dotSize, setDotSize] = useState(1.6);
  const [spotSize, setSpotSize] = useState(190);
  const [accentId, setAccentId] = useState<string>("indigo");
  const [spotOn, setSpotOn] = useState(true);
  const [driftOn, setDriftOn] = useState(true);

  const accent = ACCENTS.find((a) => a.id === accentId) ?? ACCENTS[0];

  const mx = useMotionValue(50);
  const my = useMotionValue(50);
  const sx = useSpring(mx, { stiffness: 140, damping: 22, mass: 0.6 });
  const sy = useSpring(my, { stiffness: 140, damping: 22, mass: 0.6 });
  const tx = reduce ? mx : sx;
  const ty = reduce ? my : sy;

  const mask = useMotionTemplate`radial-gradient(circle ${spotSize}px at ${tx}% ${ty}%, #000 0%, rgba(0,0,0,0.55) 46%, transparent 74%)`;

  const handlePointer = (event: PointerEvent<HTMLElement>) => {
    const rect = sectionRef.current?.getBoundingClientRect();
    if (!rect) return;
    mx.set(((event.clientX - rect.left) / rect.width) * 100);
    my.set(((event.clientY - rect.top) / rect.height) * 100);
  };

  const handleLeave = () => {
    mx.set(50);
    my.set(50);
  };

  const handleSwatchKey = (event: KeyboardEvent<HTMLDivElement>) => {
    const index = ACCENTS.findIndex((a) => a.id === accentId);
    let next = index;
    if (event.key === "ArrowRight" || event.key === "ArrowDown") {
      next = (index + 1) % ACCENTS.length;
    } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
      next = (index - 1 + ACCENTS.length) % ACCENTS.length;
    } else if (event.key === "Home") {
      next = 0;
    } else if (event.key === "End") {
      next = ACCENTS.length - 1;
    } else {
      return;
    }
    event.preventDefault();
    setAccentId(ACCENTS[next].id);
    swatchRefs.current[next]?.focus();
  };

  const baseStyle = {
    ["--bgx-gap"]: `${gap}px`,
    backgroundImage: `radial-gradient(circle at center, var(--bgx-dot) ${dotSize}px, transparent ${dotSize + 0.7}px)`,
    backgroundSize: `${gap}px ${gap}px`,
    maskImage: EDGE_MASK,
    WebkitMaskImage: EDGE_MASK,
  } as CSSProperties;

  const accentStyle = {
    ["--bgx-gap"]: `${gap}px`,
    backgroundImage: `radial-gradient(circle at center, ${accent.dot} ${dotSize + 0.5}px, transparent ${dotSize + 1.3}px)`,
    backgroundSize: `${gap}px ${gap}px`,
  } as CSSProperties;

  const glowStyle = {
    background: `radial-gradient(60% 55% at 78% 18%, ${accent.dot}22, transparent 70%), radial-gradient(50% 50% at 12% 92%, ${accent.dot}1a, transparent 72%)`,
  } as CSSProperties;

  return (
    <section
      ref={sectionRef}
      onPointerMove={handlePointer}
      onPointerLeave={handleLeave}
      aria-label="Dotted grid background demo"
      className="relative w-full overflow-hidden bg-white px-6 py-24 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:py-32"
    >
      <style>{`
        .bgxdots-base { --bgx-dot: rgba(71, 85, 105, 0.38); }
        @media (prefers-color-scheme: dark) {
          .bgxdots-base { --bgx-dot: rgba(148, 163, 184, 0.30); }
        }
        @keyframes bgxdots-drift {
          from { background-position: 0px 0px; }
          to { background-position: var(--bgx-gap) var(--bgx-gap); }
        }
        .bgxdots-animated { animation: bgxdots-drift 18s linear infinite; }
        @media (prefers-reduced-motion: reduce) {
          .bgxdots-animated { animation: none !important; }
        }
      `}</style>

      {/* soft accent atmosphere */}
      <div aria-hidden className="pointer-events-none absolute inset-0" style={glowStyle} />

      {/* base dotted grid */}
      <div
        aria-hidden
        className={[
          "bgxdots-base pointer-events-none absolute inset-0",
          driftOn && !reduce ? "bgxdots-animated" : "",
        ].join(" ")}
        style={baseStyle}
      />

      {/* accent spotlight grid following the cursor */}
      <motion.div
        aria-hidden
        className="pointer-events-none absolute inset-0"
        style={{ ...accentStyle, maskImage: mask, WebkitMaskImage: mask }}
        animate={{ opacity: spotOn ? 1 : 0 }}
        transition={{ duration: reduce ? 0 : 0.35, ease: "easeOut" }}
      />

      <div className="relative mx-auto grid max-w-5xl gap-12 lg:grid-cols-2 lg:items-center lg:gap-16">
        <div>
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-[0.18em] text-slate-500 backdrop-blur dark:border-slate-800 dark:bg-slate-900/60 dark:text-slate-400">
            <span className={`h-1.5 w-1.5 rounded-full ${accent.swatch}`} />
            Backgrounds / Dotted grid
          </span>

          <h2 className="mt-6 text-4xl font-semibold leading-[1.05] tracking-tight sm:text-5xl">
            A dotted grid that
            <br className="hidden sm:block" /> tracks the cursor.
          </h2>

          <p className="mt-5 max-w-md text-base leading-relaxed text-slate-600 dark:text-slate-400">
            Drop this behind any hero, pricing table, or dashboard. The grid is
            painted with pure CSS radial-gradients, so it stays razor-sharp at
            every zoom level and costs almost nothing to render. Move your
            pointer across the panel to light up the spotlight.
          </p>

          <dl className="mt-8 flex flex-wrap gap-x-8 gap-y-4 text-sm">
            <div>
              <dt className="text-slate-500 dark:text-slate-400">Paint cost</dt>
              <dd className="mt-0.5 font-semibold tabular-nums">2 gradients</dd>
            </div>
            <div>
              <dt className="text-slate-500 dark:text-slate-400">Spacing</dt>
              <dd className="mt-0.5 font-semibold tabular-nums">{gap}px grid</dd>
            </div>
            <div>
              <dt className="text-slate-500 dark:text-slate-400">DOM nodes</dt>
              <dd className="mt-0.5 font-semibold tabular-nums">3 layers</dd>
            </div>
          </dl>
        </div>

        <div className="rounded-2xl border border-slate-200 bg-white/75 p-6 shadow-xl shadow-slate-900/5 backdrop-blur-md dark:border-slate-800 dark:bg-slate-900/70 dark:shadow-black/30 sm:p-7">
          <div className="flex items-center justify-between">
            <h3 className="text-sm font-semibold tracking-tight">Grid controls</h3>
            <span className="rounded-md bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-500 dark:bg-slate-800 dark:text-slate-400">
              live
            </span>
          </div>

          <div className="mt-6 space-y-6">
            <div>
              <div className="flex items-baseline justify-between">
                <label htmlFor={gapId} className="text-sm font-medium">
                  Dot spacing
                </label>
                <span className="text-xs tabular-nums text-slate-500 dark:text-slate-400">
                  {gap}px
                </span>
              </div>
              <input
                id={gapId}
                type="range"
                min={14}
                max={44}
                step={1}
                value={gap}
                onChange={(event) => setGap(Number(event.target.value))}
                style={{ accentColor: accent.dot }}
                className="mt-2 w-full cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
              />
            </div>

            <div>
              <div className="flex items-baseline justify-between">
                <label htmlFor={sizeId} className="text-sm font-medium">
                  Dot size
                </label>
                <span className="text-xs tabular-nums text-slate-500 dark:text-slate-400">
                  {dotSize.toFixed(1)}px
                </span>
              </div>
              <input
                id={sizeId}
                type="range"
                min={0.8}
                max={3.4}
                step={0.2}
                value={dotSize}
                onChange={(event) => setDotSize(Number(event.target.value))}
                style={{ accentColor: accent.dot }}
                className="mt-2 w-full cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
              />
            </div>

            <div>
              <div className="flex items-baseline justify-between">
                <label htmlFor={spotId} className="text-sm font-medium">
                  Spotlight radius
                </label>
                <span className="text-xs tabular-nums text-slate-500 dark:text-slate-400">
                  {spotSize}px
                </span>
              </div>
              <input
                id={spotId}
                type="range"
                min={90}
                max={340}
                step={10}
                value={spotSize}
                disabled={!spotOn}
                onChange={(event) => setSpotSize(Number(event.target.value))}
                style={{ accentColor: accent.dot }}
                className="mt-2 w-full cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-40 dark:focus-visible:ring-offset-slate-900"
              />
            </div>

            <div>
              <span id={`${gapId}-accent`} className="text-sm font-medium">
                Accent
              </span>
              <div
                role="radiogroup"
                aria-labelledby={`${gapId}-accent`}
                onKeyDown={handleSwatchKey}
                className="mt-2 flex flex-wrap gap-2"
              >
                {ACCENTS.map((item, index) => {
                  const selected = item.id === accentId;
                  return (
                    <button
                      key={item.id}
                      ref={(node) => {
                        swatchRefs.current[index] = node;
                      }}
                      type="button"
                      role="radio"
                      aria-checked={selected}
                      aria-label={item.label}
                      tabIndex={selected ? 0 : -1}
                      onClick={() => setAccentId(item.id)}
                      className={[
                        "h-8 w-8 rounded-full outline-none transition-transform",
                        item.swatch,
                        "focus-visible:ring-2 focus-visible:ring-offset-2",
                        "focus-visible:ring-slate-900 focus-visible:ring-offset-white",
                        "dark:focus-visible:ring-white dark:focus-visible:ring-offset-slate-900",
                        selected
                          ? "scale-110 ring-2 ring-slate-900 ring-offset-2 ring-offset-white dark:ring-white dark:ring-offset-slate-900"
                          : "opacity-80 hover:opacity-100",
                      ].join(" ")}
                    />
                  );
                })}
              </div>
            </div>

            <div className="space-y-3 border-t border-slate-200 pt-5 dark:border-slate-800">
              <div className="flex items-center justify-between">
                <span className="text-sm font-medium">Cursor spotlight</span>
                <Switch checked={spotOn} onChange={setSpotOn} label="Toggle cursor spotlight" />
              </div>
              <div className="flex items-center justify-between">
                <div>
                  <span className="text-sm font-medium">Ambient drift</span>
                  {reduce ? (
                    <p className="text-xs text-slate-500 dark:text-slate-400">
                      Paused — reduced motion is on
                    </p>
                  ) : null}
                </div>
                <Switch checked={driftOn} onChange={setDriftOn} label="Toggle ambient drift animation" />
              </div>
            </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 →