A dark control card for a desk light with coloured light spilling out from behind it. The glow is one blurred gradient layer under the card, and a row of buttons switches how it moves: turning like a conic wheel, pulsing in size and strength, breathing slowly, cycling between colours or sliding from side to side. A second, sharper copy sits tight to the edge so the card also gets a thin lit border. The demo steps through the modes until a visitor picks one. On phones the copy and buttons stack above the card.
// Adapted from Motion Primitives (https://motion-primitives.com/docs/glow-effect)
// Copyright (c) ibelick. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "framer-motion";
type GlowMode = "rotate" | "pulse" | "breathe" | "colorShift" | "flowHorizontal";
const scenes: { mode: GlowMode; label: string; scene: string; colors: string[]; duration: number }[] = [
{ mode: "rotate", label: "Rotate", scene: "Aurora", colors: ["#22d3ee", "#a78bfa", "#f472b6", "#34d399"], duration: 5 },
{ mode: "pulse", label: "Pulse", scene: "Ember", colors: ["#f97316", "#ef4444", "#fbbf24"], duration: 4 },
{ mode: "breathe", label: "Breathe", scene: "Night light", colors: ["#fde68a", "#fdba74", "#fda4af"], duration: 6 },
{ mode: "colorShift", label: "Colour shift", scene: "Arcade", colors: ["#e879f9", "#38bdf8", "#a3e635", "#facc15"], duration: 6 },
{ mode: "flowHorizontal", label: "Flow", scene: "Tide", colors: ["#2dd4bf", "#3b82f6", "#818cf8"], duration: 5 },
];
/** A blurred, animated gradient that sits behind any block. Give the parent `relative`. */
function GlowEffect({ colors, mode, duration = 5, scale = 1, className = "" }: { colors: string[]; mode: GlowMode; duration?: number; scale?: number; className?: string }) {
const reduceMotion = useReducedMotion();
// The loop drives the gradient; a single value such as a fixed opacity just fades in once.
const settle = { duration: 0.6, repeat: 0 };
const loop = { repeat: Infinity, duration, ease: "linear" as const, opacity: settle, scale: settle };
const mirror = { repeat: Infinity, duration, ease: "linear" as const, repeatType: "mirror" as const };
const mirrorFixed = { ...mirror, opacity: settle, scale: settle };
const radial = colors.map((color) => `radial-gradient(circle at 50% 50%, ${color} 0%, transparent 100%)`);
const animations = {
rotate: {
background: [`conic-gradient(from 0deg at 50% 50%, ${colors.join(", ")}, ${colors[0]})`, `conic-gradient(from 360deg at 50% 50%, ${colors.join(", ")}, ${colors[0]})`],
scale,
opacity: 1,
transition: loop,
},
pulse: { background: radial, scale: [scale, 1.12 * scale, scale], opacity: [0.55, 0.95, 0.55], transition: mirror },
breathe: { background: radial, scale: [scale, 1.06 * scale, scale], opacity: 1, transition: { ...mirror, opacity: settle } },
colorShift: {
background: colors.map((color, index) => {
const next = colors[(index + 1) % colors.length];
return `conic-gradient(from 0deg at 50% 50%, ${color} 0%, ${next} 50%, ${color} 100%)`;
}),
scale,
opacity: 1,
transition: mirrorFixed,
},
flowHorizontal: {
background: colors.map((color, index) => `linear-gradient(to right, ${color}, ${colors[(index + 1) % colors.length]})`),
scale,
opacity: 1,
transition: mirrorFixed,
},
};
if (reduceMotion) {
return <div aria-hidden="true" className={`pointer-events-none absolute inset-0 ${className}`} style={{ background: `linear-gradient(to right, ${colors.join(", ")})` }} />;
}
return (
<motion.div
key={mode + colors.join("")}
aria-hidden="true"
className={`pointer-events-none absolute inset-0 ${className}`}
style={{ willChange: "transform", backfaceVisibility: "hidden" }}
initial={{ opacity: 0 }}
animate={animations[mode]}
/>
);
}
export default function AnimatedGlowBehindCardWithFiveModes() {
const reduceMotion = useReducedMotion();
const [index, setIndex] = useState(0);
const [auto, setAuto] = useState(true);
const [level, setLevel] = useState(70);
const sectionRef = useRef<HTMLElement>(null);
const current = scenes[index];
// Step through the modes until a visitor picks one.
useEffect(() => {
if (!auto || reduceMotion) return;
const timer = setInterval(() => {
if (!document.hidden) setIndex((value) => (value + 1) % scenes.length);
}, 2800);
return () => clearInterval(timer);
}, [auto, reduceMotion]);
return (
<section ref={sectionRef} className="overflow-hidden bg-slate-950 px-5 py-14 text-slate-100 sm:px-8 sm:py-20">
<div className="mx-auto grid max-w-5xl items-center gap-12 lg:grid-cols-2">
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-slate-400">Halo Bar, desk light</p>
<h2 className="mt-4 text-4xl font-bold leading-tight tracking-tight sm:text-5xl">Light that leaks out from behind.</h2>
<p className="mt-5 max-w-md text-base leading-7 text-slate-300">
One blurred gradient layer under the card, five ways to move it. Pick a mode to see how it turns, pulses, breathes, shifts colour or flows.
</p>
<div role="radiogroup" aria-label="Glow mode" className="mt-8 flex flex-wrap gap-2">
{scenes.map((scene, sceneIndex) => (
<button
key={scene.mode}
type="button"
role="radio"
aria-checked={sceneIndex === index}
onClick={() => {
setAuto(false);
setIndex(sceneIndex);
}}
className={`inline-flex h-11 items-center gap-2 rounded-full border px-4 text-sm font-semibold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white ${
sceneIndex === index ? "border-white bg-white text-slate-950" : "border-white/15 text-slate-200 hover:border-white/40"
}`}
>
<span aria-hidden="true" className="h-3 w-3 rounded-full" style={{ background: `linear-gradient(135deg, ${scene.colors[0]}, ${scene.colors[1]})` }} />
{scene.label}
</button>
))}
</div>
<p className="mt-4 font-mono text-xs text-slate-400">
mode="{current.mode}" duration={current.duration}s colors={current.colors.length}
</p>
</div>
{/* The card. The glow is the first child, the surface sits on top of it. */}
<div className="relative mx-auto w-full max-w-sm">
<GlowEffect colors={current.colors} mode={current.mode} duration={current.duration} className="rounded-[2rem] blur-2xl" scale={1.02} />
<GlowEffect colors={current.colors} mode={current.mode} duration={current.duration} className="rounded-[2rem] blur-sm" />
<div className="relative m-px rounded-[2rem] bg-slate-900 p-6 sm:p-7">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-white">Halo Bar</h3>
<p className="text-sm text-slate-400">Studio desk, left monitor</p>
</div>
<span className="flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs font-semibold text-white">
<span className="h-2 w-2 rounded-full bg-emerald-400" />
On
</span>
</div>
{/* The lamp itself */}
<div className="mt-7 flex h-28 items-center justify-center rounded-2xl bg-slate-950">
<div className="relative h-3 w-4/5 rounded-full" style={{ background: `linear-gradient(to right, ${current.colors.join(", ")})`, opacity: 0.35 + level / 154 }}>
<div aria-hidden="true" className="absolute -inset-3 rounded-full blur-xl" style={{ background: `linear-gradient(to right, ${current.colors.join(", ")})`, opacity: level / 130 }} />
</div>
</div>
<dl className="mt-6 grid grid-cols-3 gap-3 text-sm">
<div>
<dt className="text-xs text-slate-400">Scene</dt>
<dd className="mt-0.5 font-semibold text-white">{current.scene}</dd>
</div>
<div>
<dt className="text-xs text-slate-400">Motion</dt>
<dd className="mt-0.5 font-semibold text-white">{current.label}</dd>
</div>
<div>
<dt className="text-xs text-slate-400">Power</dt>
<dd className="mt-0.5 font-semibold text-white">{Math.round(level * 0.09 * 10) / 10} W</dd>
</div>
</dl>
<div className="mt-6">
<div className="flex items-center justify-between text-sm">
<label htmlFor="glow-modes-brightness" className="font-medium text-slate-200">
Brightness
</label>
<span className="font-mono text-slate-300">{level}%</span>
</div>
<input
id="glow-modes-brightness"
type="range"
min={10}
max={100}
value={level}
onChange={(event) => setLevel(Number(event.target.value))}
className="mt-3 h-10 w-full cursor-pointer accent-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
/>
</div>
</div>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/animated-glow-behind-card-with-five-modes.json
An absolutely positioned layer fills the same box as the card, carries a gradient background and a CSS blur, and sits below the card's surface. The blur spreads the colour past the edges, which reads as light coming from behind.
The background is a conic gradient and its starting angle is animated from 0 to 360 degrees. Because the first colour is repeated at the end of the list, the wheel has no seam.
Both step through soft radial gradients in each colour. Pulse also changes size by 12 percent and fades between 55 and 95 percent strength, so it is clearly rhythmic. Breathe only grows by 6 percent and stays at full strength.
It repaints the glow layer on each frame, which costs more than a transform. One or two glows per screen is fine. For many glowing items, use a static gradient and animate only opacity or scale.
Yes. Wrap the button in a `relative inline-block` element, render `GlowEffect` first with a matching radius and a small blur such as `blur-md`, and give the button `relative` so it sits above.
Adapted from Glow Effect in Motion Primitives, used under its open-source licence. Licences and credits
Related: Animated Gradient Border Card Wrapper, Breathing Glow Border Status Panel, Card with Travelling Border Beam.
More borders and glows or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.