Web InnoventixFreeCode

Pulse Loader

Original · free

pulsing circle loader

byWeb InnoventixReact + Tailwind
loadpulseloaders
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/load-pulse.json
load-pulse.tsx
"use client"

import { useRef, useState, type KeyboardEvent } from "react"
import { motion, useReducedMotion } from "motion/react"

type Accent = "indigo" | "violet" | "emerald" | "rose" | "sky" | "amber"
type Size = "sm" | "md" | "lg"
type Rings = "2" | "3" | "4"

type Option<T extends string> = { value: T; label: string; swatchClass?: string }

const ACCENTS: Record<
  Accent,
  {
    ringBorder: string
    core: string
    glow: string
    swatch: string
    rangeAccent: string
    chipText: string
  }
> = {
  indigo: {
    ringBorder: "border-indigo-500 dark:border-indigo-400",
    core: "bg-indigo-500 dark:bg-indigo-400",
    glow: "bg-indigo-400/25 dark:bg-indigo-500/25",
    swatch: "bg-indigo-500",
    rangeAccent: "accent-indigo-500 dark:accent-indigo-400",
    chipText: "text-indigo-600 dark:text-indigo-300",
  },
  violet: {
    ringBorder: "border-violet-500 dark:border-violet-400",
    core: "bg-violet-500 dark:bg-violet-400",
    glow: "bg-violet-400/25 dark:bg-violet-500/25",
    swatch: "bg-violet-500",
    rangeAccent: "accent-violet-500 dark:accent-violet-400",
    chipText: "text-violet-600 dark:text-violet-300",
  },
  emerald: {
    ringBorder: "border-emerald-500 dark:border-emerald-400",
    core: "bg-emerald-500 dark:bg-emerald-400",
    glow: "bg-emerald-400/25 dark:bg-emerald-500/25",
    swatch: "bg-emerald-500",
    rangeAccent: "accent-emerald-500 dark:accent-emerald-400",
    chipText: "text-emerald-600 dark:text-emerald-300",
  },
  rose: {
    ringBorder: "border-rose-500 dark:border-rose-400",
    core: "bg-rose-500 dark:bg-rose-400",
    glow: "bg-rose-400/25 dark:bg-rose-500/25",
    swatch: "bg-rose-500",
    rangeAccent: "accent-rose-500 dark:accent-rose-400",
    chipText: "text-rose-600 dark:text-rose-300",
  },
  sky: {
    ringBorder: "border-sky-500 dark:border-sky-400",
    core: "bg-sky-500 dark:bg-sky-400",
    glow: "bg-sky-400/25 dark:bg-sky-500/25",
    swatch: "bg-sky-500",
    rangeAccent: "accent-sky-500 dark:accent-sky-400",
    chipText: "text-sky-600 dark:text-sky-300",
  },
  amber: {
    ringBorder: "border-amber-500 dark:border-amber-400",
    core: "bg-amber-500 dark:bg-amber-400",
    glow: "bg-amber-400/25 dark:bg-amber-500/25",
    swatch: "bg-amber-500",
    rangeAccent: "accent-amber-500 dark:accent-amber-400",
    chipText: "text-amber-600 dark:text-amber-300",
  },
}

const SIZES: Record<Size, number> = { sm: 150, md: 210, lg: 270 }
const SIZE_LABEL: Record<Size, string> = { sm: "Small", md: "Medium", lg: "Large" }

const ACCENT_LIST: Accent[] = ["indigo", "violet", "emerald", "rose", "sky", "amber"]
const ACCENT_OPTIONS: Option<Accent>[] = ACCENT_LIST.map((v) => ({
  value: v,
  label: v.charAt(0).toUpperCase() + v.slice(1),
  swatchClass: ACCENTS[v].swatch,
}))
const SIZE_OPTIONS: Option<Size>[] = [
  { value: "sm", label: "Small" },
  { value: "md", label: "Medium" },
  { value: "lg", label: "Large" },
]
const RING_OPTIONS: Option<Rings>[] = [
  { value: "2", label: "2" },
  { value: "3", label: "3" },
  { value: "4", label: "4" },
]

function PlayIcon() {
  return (
    <svg viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor" aria-hidden="true">
      <path d="M8 5.14v13.72a1 1 0 0 0 1.53.85l10.79-6.86a1 1 0 0 0 0-1.7L9.53 4.29A1 1 0 0 0 8 5.14Z" />
    </svg>
  )
}

function PauseIcon() {
  return (
    <svg viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor" aria-hidden="true">
      <rect x="6" y="5" width="4" height="14" rx="1" />
      <rect x="14" y="5" width="4" height="14" rx="1" />
    </svg>
  )
}

function ResetIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      className="h-4 w-4"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.9"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M3 12a9 9 0 1 0 3-6.7" />
      <path d="M3 4v5h5" />
    </svg>
  )
}

function Segmented<T extends string>({
  legend,
  value,
  options,
  onChange,
}: {
  legend: string
  value: T
  options: Option<T>[]
  onChange: (next: T) => void
}) {
  const btns = useRef<Array<HTMLButtonElement | null>>([])
  const active = Math.max(
    0,
    options.findIndex((o) => o.value === value),
  )

  const focusAt = (i: number) => {
    const n = (i + options.length) % options.length
    onChange(options[n].value)
    btns.current[n]?.focus()
  }

  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    switch (e.key) {
      case "ArrowRight":
      case "ArrowDown":
        e.preventDefault()
        focusAt(active + 1)
        break
      case "ArrowLeft":
      case "ArrowUp":
        e.preventDefault()
        focusAt(active - 1)
        break
      case "Home":
        e.preventDefault()
        focusAt(0)
        break
      case "End":
        e.preventDefault()
        focusAt(options.length - 1)
        break
      default:
        break
    }
  }

  return (
    <div role="radiogroup" aria-label={legend} onKeyDown={onKeyDown} className="flex flex-wrap gap-2">
      {options.map((o, i) => {
        const selected = o.value === value
        return (
          <button
            key={o.value}
            ref={(el) => {
              btns.current[i] = el
            }}
            type="button"
            role="radio"
            aria-checked={selected}
            tabIndex={selected ? 0 : -1}
            onClick={() => onChange(o.value)}
            className={[
              "inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors",
              "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-sky-400 dark:focus-visible:ring-offset-slate-900",
              selected
                ? "border-slate-900 bg-slate-900 text-white dark:border-white dark:bg-white dark:text-slate-900"
                : "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:text-white",
            ].join(" ")}
          >
            {o.swatchClass ? (
              <span className={`h-3 w-3 rounded-full ${o.swatchClass}`} aria-hidden="true" />
            ) : null}
            {o.label}
          </button>
        )
      })}
    </div>
  )
}

export default function LoadPulse() {
  const reduce = useReducedMotion() ?? false

  const [playing, setPlaying] = useState<boolean>(true)
  const [speed, setSpeed] = useState<number>(1)
  const [accent, setAccent] = useState<Accent>("indigo")
  const [size, setSize] = useState<Size>("md")
  const [rings, setRings] = useState<Rings>("3")

  const a = ACCENTS[accent]
  const dim = SIZES[size]
  const ringCount = parseInt(rings, 10)
  const duration = 1.8 / speed

  const reset = () => {
    setPlaying(true)
    setSpeed(1)
    setAccent("indigo")
    setSize("md")
    setRings("3")
  }

  const statusText = playing
    ? `Loading — ${ringCount} rings, ${speed.toFixed(1)}× tempo, ${accent} accent`
    : "Loader paused"

  const enter = (delay: number) =>
    reduce
      ? { initial: false as const }
      : {
          initial: { opacity: 0, y: 16 },
          animate: { opacity: 1, y: 0 },
          transition: { duration: 0.5, ease: "easeOut" as const, delay },
        }

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 py-16 sm:py-24 dark:bg-slate-950">
      <style>{`
        @keyframes lpulse-ring {
          0%   { transform: scale(0.28); opacity: 0.55; }
          70%  { opacity: 0; }
          100% { transform: scale(1); opacity: 0; }
        }
        @keyframes lpulse-core {
          0%, 100% { transform: scale(1); }
          50%      { transform: scale(1.18); }
        }
        @media (prefers-reduced-motion: reduce) {
          .lpulse-ring, .lpulse-core { animation: none !important; }
        }
      `}</style>

      {/* atmosphere */}
      <div aria-hidden="true" className="pointer-events-none absolute inset-0">
        <div className={`absolute -left-24 top-8 h-72 w-72 rounded-full blur-3xl ${a.glow}`} />
        <div className={`absolute -right-16 bottom-0 h-80 w-80 rounded-full blur-3xl ${a.glow}`} />
        <div
          className="absolute inset-0 opacity-[0.4] dark:opacity-[0.25]"
          style={{
            backgroundImage:
              "radial-gradient(circle at 1px 1px, rgba(100,116,139,0.18) 1px, transparent 0)",
            backgroundSize: "26px 26px",
          }}
        />
      </div>

      <div className="relative mx-auto max-w-5xl px-4 sm:px-6">
        <motion.header {...enter(0)} className="mx-auto max-w-2xl text-center">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-semibold uppercase tracking-wider text-slate-600 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
            <svg
              viewBox="0 0 24 24"
              className={`h-3.5 w-3.5 ${a.chipText}`}
              fill="none"
              stroke="currentColor"
              strokeWidth="1.8"
              aria-hidden="true"
            >
              <circle cx="12" cy="12" r="3" />
              <circle cx="12" cy="12" r="7.5" opacity="0.5" />
            </svg>
            Loaders · Pulse
          </span>
          <h2 className="mt-5 text-balance text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
            A breathing loader for slow async states
          </h2>
          <p className="mx-auto mt-4 max-w-xl text-pretty text-base leading-relaxed text-slate-600 dark:text-slate-400">
            Drop it in while data streams, an upload settles, or a route hydrates. Every parameter
            below is live — tune the tempo, palette, and ring density until it matches your UI.
          </p>
        </motion.header>

        <div className="mt-12 grid gap-6 lg:grid-cols-[1.05fr_0.95fr]">
          {/* stage */}
          <motion.div
            {...enter(0.08)}
            className="relative flex flex-col items-center justify-center rounded-2xl border border-slate-200 bg-white p-8 dark:border-slate-800 dark:bg-slate-900"
          >
            <div className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-medium text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
              <span
                aria-hidden="true"
                className={`h-2 w-2 rounded-full ${playing ? a.core : "bg-slate-400"}`}
              />
              {playing ? "Loading" : "Paused"}
            </div>

            <div
              role="status"
              aria-busy={playing}
              aria-live="polite"
              className="relative my-10 flex items-center justify-center"
              style={{ width: dim, height: dim }}
            >
              {Array.from({ length: ringCount }).map((_, i) => (
                <span
                  key={i}
                  aria-hidden="true"
                  className={`lpulse-ring absolute rounded-full border-2 ${a.ringBorder}`}
                  style={{
                    width: dim,
                    height: dim,
                    opacity: 0.14 + (i / ringCount) * 0.4,
                    transform: `scale(${0.3 + (i / ringCount) * 0.7})`,
                    animation: `lpulse-ring ${duration}s cubic-bezier(0.22, 0.61, 0.36, 1) infinite`,
                    animationDelay: `${(i * duration) / ringCount}s`,
                    animationPlayState: playing ? "running" : "paused",
                  }}
                />
              ))}
              <span
                aria-hidden="true"
                className={`lpulse-core relative rounded-full shadow-lg ${a.core}`}
                style={{
                  width: dim * 0.22,
                  height: dim * 0.22,
                  animation: `lpulse-core ${duration}s ease-in-out infinite`,
                  animationPlayState: playing ? "running" : "paused",
                }}
              />
              <span className="sr-only">{statusText}</span>
            </div>

            <div className="flex flex-wrap items-center justify-center gap-2 text-xs">
              <span className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 font-medium text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
                {ringCount} rings
              </span>
              <span className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 font-medium tabular-nums text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
                {speed.toFixed(1)}× tempo
              </span>
              <span className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 font-medium text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
                {SIZE_LABEL[size]}
              </span>
              <span
                className={`rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 font-semibold dark:border-slate-700 dark:bg-slate-800 ${a.chipText}`}
              >
                {accent}
              </span>
            </div>
          </motion.div>

          {/* controls */}
          <motion.div
            {...enter(0.16)}
            className="rounded-2xl border border-slate-200 bg-white p-6 sm:p-7 dark:border-slate-800 dark:bg-slate-900"
          >
            <div className="flex items-center justify-between gap-4">
              <h3 className="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
                Controls
              </h3>
              <button
                type="button"
                onClick={reset}
                className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-600 transition-colors hover:border-slate-300 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:text-white dark:focus-visible:ring-sky-400 dark:focus-visible:ring-offset-slate-900"
              >
                <ResetIcon />
                Reset
              </button>
            </div>

            <div className="mt-5 flex items-center gap-3">
              <motion.button
                type="button"
                onClick={() => setPlaying((p) => !p)}
                aria-pressed={playing}
                whileTap={reduce ? undefined : { scale: 0.96 }}
                className="inline-flex flex-1 items-center justify-center gap-2 rounded-xl bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 dark:focus-visible:ring-sky-400 dark:focus-visible:ring-offset-slate-900"
              >
                {playing ? <PauseIcon /> : <PlayIcon />}
                {playing ? "Pause" : "Play"}
              </motion.button>
            </div>

            <div className="mt-6 space-y-6">
              <div>
                <label className="block">
                  <span className="mb-2 flex items-center justify-between text-sm font-medium text-slate-700 dark:text-slate-300">
                    <span>Tempo</span>
                    <span className="tabular-nums text-slate-500 dark:text-slate-400">
                      {speed.toFixed(1)}×
                    </span>
                  </span>
                  <input
                    type="range"
                    min={0.5}
                    max={2}
                    step={0.1}
                    value={speed}
                    onChange={(e) => setSpeed(parseFloat(e.target.value))}
                    aria-label="Animation tempo multiplier"
                    className={`h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-slate-700 dark:focus-visible:ring-sky-400 dark:focus-visible:ring-offset-slate-900 ${a.rangeAccent}`}
                  />
                </label>
              </div>

              <div>
                <span className="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300">
                  Accent
                </span>
                <Segmented<Accent>
                  legend="Accent color"
                  value={accent}
                  options={ACCENT_OPTIONS}
                  onChange={setAccent}
                />
              </div>

              <div>
                <span className="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300">
                  Size
                </span>
                <Segmented<Size>
                  legend="Loader size"
                  value={size}
                  options={SIZE_OPTIONS}
                  onChange={setSize}
                />
              </div>

              <div>
                <span className="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300">
                  Ring density
                </span>
                <Segmented<Rings>
                  legend="Number of rings"
                  value={rings}
                  options={RING_OPTIONS}
                  onChange={setRings}
                />
              </div>
            </div>

            <p className="mt-6 border-t border-slate-200 pt-4 text-xs leading-relaxed text-slate-500 dark:border-slate-800 dark:text-slate-400">
              Reduced-motion visitors see a calm, static set of concentric rings instead of the
              ripple — no animation, same meaning.
            </p>
          </motion.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 →