Pulse Loader
Original · freepulsing circle loader
byWeb InnoventixReact + Tailwind
loadpulseloaders
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.jsonload-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 quoteSimilar components
Browse all →
Spinner Set Loader
Originalset of circular spinners in sizes

Dots Loader
Originalbouncing dots loader

Bars Loader
Originalequalizer bars loader

Ring Loader
Originalring spinner loader

Orbit Loader
Originalorbiting dots loader

Wave Loader
Originalwave loader

Bounce Loader
Originalbouncing ball loader

Gradient Ring Loader
Originalgradient conic ring spinner

Dual Ring Loader
Originaldual counter-rotating rings

Square Loader
Originalmorphing square loader

Typing Loader
Originaltyping indicator loader

Page Overlay Loader
Originalfull-page loading overlay with progress

