Aurora UI Effects: Animated Northern Lights in CSS and React
Build animated aurora borealis effects in React and CSS — shifting color gradients, blur layers, and keyframe animations that make any UI feel alive without heavy libraries.
What Aurora UI Effects Actually Are
Honestly, most "aurora" UI effects you see on Dribbble are just two blurred circles and a CSS filter — and that's fine. The real version is a little more interesting. Aurora UI takes its cues from the actual northern lights: slow-shifting color bands, soft radial glows, and overlapping translucent layers that blend at the edges rather than having hard stops.
The core technique involves stacking multiple absolutely-positioned gradient blobs, animating their positions and opacity over time using CSS keyframes, and applying filter: blur() at 60–120px to smear the edges into that signature soft glow. The whole thing lives behind your actual content, which sits above on a dark background.
This style has taken off in 2025–2026 largely because it plays well with dark mode and feels distinct from glassmorphism without requiring the same backdrop-filter performance overhead. You're animating transforms on cheap GPU layers, not blurring the DOM in real time.
The CSS Foundation: Gradient Blobs and Keyframe Drift
Start with a dark container — #050510 or similar near-black with a slight blue tint. Then drop in three to five absolutely positioned div elements, each with a radial gradient set to a single aurora color: mint green (#00ffaa), violet (#7b2fff), cyan (#00d4ff), rose (#ff3d7f). These are your light sources.
Each blob gets a border-radius: 50%, a width and height somewhere between 40vw and 80vw, and filter: blur(80px). Set opacity to around 0.35–0.55. Too opaque and it reads as neon; too low and it disappears on non-OLED screens.
Here's a minimal CSS setup that gives you the drifting effect:
.aurora-container {
position: relative;
width: 100%;
min-height: 100vh;
background: #050510;
overflow: hidden;
}
.blob {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.45;
animation: drift 12s ease-in-out infinite alternate;
}
.blob-1 {
width: 60vw;
height: 60vw;
background: radial-gradient(circle, #00ffaa 0%, transparent 70%);
top: -20%;
left: -10%;
animation-duration: 14s;
}
.blob-2 {
width: 50vw;
height: 50vw;
background: radial-gradient(circle, #7b2fff 0%, transparent 70%);
top: 30%;
right: -15%;
animation-duration: 18s;
animation-delay: -6s;
}
.blob-3 {
width: 45vw;
height: 45vw;
background: radial-gradient(circle, #00d4ff 0%, transparent 70%);
bottom: -10%;
left: 25%;
animation-duration: 22s;
animation-delay: -10s;
}
@keyframes drift {
0% {
transform: translate(0, 0) scale(1);
}
33% {
transform: translate(4vw, -6vh) scale(1.08);
}
66% {
transform: translate(-3vw, 4vh) scale(0.95);
}
100% {
transform: translate(2vw, -2vh) scale(1.04);
}
}Notice the staggered animation-duration and negative animation-delay values — this desynchronizes the blobs so they never move in lockstep. That's what makes it feel organic rather than mechanical.
Building the React Component
Wrapping this in a React component is straightforward. You want it to accept a colors prop for customization and a children prop so it works as a layout wrapper. Keep the blobs data-driven so adding or removing light sources doesn't require touching JSX structure.
import React from 'react';
interface AuroraBlobConfig {
color: string;
size: string;
top?: string;
left?: string;
right?: string;
bottom?: string;
duration: number;
delay: number;
}
const DEFAULT_BLOBS: AuroraBlobConfig[] = [
{ color: '#00ffaa', size: '60vw', top: '-20%', left: '-10%', duration: 14, delay: 0 },
{ color: '#7b2fff', size: '50vw', top: '30%', right: '-15%', duration: 18, delay: -6 },
{ color: '#00d4ff', size: '45vw', bottom: '-10%', left: '25%', duration: 22, delay: -10 },
{ color: '#ff3d7f', size: '35vw', top: '60%', left: '5%', duration: 16, delay: -4 },
];
interface AuroraBackgroundProps {
blobs?: AuroraBlobConfig[];
children?: React.ReactNode;
className?: string;
}
export function AuroraBackground({
blobs = DEFAULT_BLOBS,
children,
className = '',
}: AuroraBackgroundProps) {
return (
<div
className={`relative overflow-hidden bg-[#050510] ${className}`}
style={{ isolation: 'isolate' }}
>
{blobs.map((blob, i) => (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
width: blob.size,
height: blob.size,
borderRadius: '50%',
filter: 'blur(80px)',
opacity: 0.45,
background: `radial-gradient(circle, ${blob.color} 0%, transparent 70%)`,
top: blob.top,
left: blob.left,
right: blob.right,
bottom: blob.bottom,
animation: `aurora-drift ${blob.duration}s ease-in-out ${blob.delay}s infinite alternate`,
}}
/>
))}
<div className="relative z-10">{children}</div>
</div>
);
}The isolation: isolate on the container creates a new stacking context so the blobs don't accidentally bleed through other positioned elements in your page. Worth setting explicitly rather than relying on z-index luck.
One thing people miss: add aria-hidden="true" to every blob div. Screen readers don't need to know about decorative animated divs, and it keeps the accessibility tree clean.
Tailwind Integration and the Color Shifting Trick
If you're using Tailwind v4.0.2 or later, you can define the aurora keyframes directly in your @layer and reference them via arbitrary values. Earlier Tailwind versions need the keyframes in tailwind.config.ts under theme.extend.keyframes.
The color-shifting version of the effect — where the blobs themselves change color over time — requires either CSS custom properties or the @property rule for smooth transitions between gradient stops. Pure background animations don't interpolate between radial-gradient values in most browsers. But you can animate a custom property instead:
@property --aurora-hue {
syntax: '<number>';
inherits: false;
initial-value: 160;
}
.blob-hue-shift {
background: radial-gradient(
circle,
hsl(var(--aurora-hue) 100% 60%) 0%,
transparent 70%
);
animation: hue-cycle 20s linear infinite;
}
@keyframes hue-cycle {
from { --aurora-hue: 160; }
to { --aurora-hue: 280; }
}This cycles the blob from green (hue 160) through cyan and blue to violet (hue 280) and back. The @property registration is what allows the browser to interpolate the custom property numerically — without it the transition would just snap. Browser support is solid as of mid-2025: Chrome, Edge, Firefox 128+, Safari 16.4+. If you need older Safari, fall back to the static color approach.
Performance Considerations You Actually Need to Know
Aurora effects are GPU-friendly when done right. transform and opacity are composited properties — the browser hands them off to the GPU and doesn't repaint the DOM. That's why the drift keyframes above only use transform: translate() scale() rather than animating top, left, or width. Never animate layout properties inside a loop animation.
The filter: blur() is the expensive part. At blur(80px) on a 60vw element, you're asking the GPU to blur a very large texture every frame. On mobile this can dip to 30fps. A few options: reduce blur radius to 40–60px on smaller screens using a media query, drop the blob count from 4 to 2 on mobile, or use will-change: transform on each blob (though measure first — it allocates GPU memory, so don't apply it to everything).
Also worth checking: does your use case actually need continuous animation? For hero sections that are visible for seconds, it's fine. For components the user scrolls past in 200ms, you're burning battery for nothing. The prefers-reduced-motion media query should pause or disable the animation entirely — this isn't optional if you want to ship a responsible component. Compare this with how particles background effects handle the same reduced-motion concern.
Aurora vs Other Dark-Mode Styles
Aurora sits in an interesting position relative to other visual styles. It's darker and more atmospheric than glassmorphism — glass needs visible content behind it to work, while aurora is itself the background. It's warmer and more dynamic than flat dark mode. And compared to neumorphism, it has no surface metaphor at all — it's purely light physics.
Where does it fit? Hero sections, full-page backgrounds for SaaS dashboards, login screens, and portfolio sites. It doesn't work well as a card or component-level style because the blobs need room to breathe. Trying to squeeze an aurora effect into a 320px card just looks like a blurry mess.
Why does aurora pair so well with dark mode specifically? The blending math. Additive blending on dark backgrounds creates the glow effect naturally. On white or light backgrounds those same gradients look washed out and flat — you'd need mix-blend-mode: multiply and different color choices to get anywhere close. If you're building a theme toggle that switches between light and dark, plan to disable or replace the aurora layer in light mode rather than trying to adapt it.
Composing Aurora with Content Layers
The component works as a wrapper, but you'll often want to add a subtle noise texture overlay to break up the digital smoothness — real aurora has texture. A 200x200 SVG noise filter at opacity: 0.04 does it without hurting performance. Add it as a pseudo-element on the container.
Glass cards sit beautifully on top of aurora backgrounds. Use background: rgba(255,255,255,0.05) with backdrop-filter: blur(12px) and a 1px solid rgba(255,255,255,0.08) border. The aurora colors bleed through the glass just enough to feel connected. For more detail on that glass-on-dark combination, the best free glassmorphism components article covers several ready-to-use implementations.
Text legibility is your main risk. The shifting colors mean that at some animation frames, low-contrast text might dip below WCAG AA. Either fix your text on a semi-opaque dark surface (background: rgba(0,0,0,0.4) underneath text blocks), or ensure all text is white with sufficient size and weight that it stays legible even against the lightest blob areas. Don't rely on the animation being in a particular state — test at every frame.
Empire UI Aurora Component: What's Included
Empire UI ships an <AuroraBackground> component pre-configured for the 40 visual styles in the library. The aurora variant includes four blob presets (Midnight, Nebula, Nordic, and Synthwave), a TypeScript-typed props API, and the @property hue-cycling variant as an opt-in. All of it is zero-dependency beyond React and Tailwind.
The component exposes an intensity prop ('subtle' | 'medium' | 'vivid') that scales opacity from 0.25 to 0.65 and blur from 60px to 100px. There's also a speed multiplier prop that scales all animation durations proportionally — useful if you want a very slow, meditative drift versus a more active animated feel.
You can also drop to pure CSS if you'd rather not use the component wrapper. The library's aurora styles are available as a standalone CSS file that you can import directly. This matters if you're using a framework without React — it's just CSS classes, no JSX required.
FAQ
No. filter is a composited property when applied to a GPU layer. It doesn't trigger layout or paint — it's handled in the compositor thread. The main concern is texture memory, not reflow. Keep individual blob sizes reasonable (under 80vw) and you'll stay in safe territory on most devices.
Absolutely. The keyframe animations and blob styles are plain CSS — no Tailwind utilities are strictly required. The React component uses a few Tailwind classes for convenience (overflow-hidden, relative, z-10) but those are trivially replaceable with CSS Module classes or inline styles. The @property hue-cycling trick works the same in any setup.
Wrap your animation declarations in a @media (prefers-reduced-motion: no-preference) block, or override them in a @media (prefers-reduced-motion: reduce) block that sets animation: none. In the React component, you can read the preference via window.matchMedia('(prefers-reduced-motion: reduce)').matches and conditionally skip rendering the blob divs entirely, which is cleaner than CSS-only pausing.
Safari handles very large blurred elements differently from Chrome. Try adding -webkit-transform: translateZ(0) to force GPU promotion on Safari, or reduce the blur radius to 60px and increase the blob count to compensate. Also check that you're not animating opacity and transform simultaneously on the same element in older Safari — split them across a parent/child wrapper if needed.
Yes. Track mousemove on the container and update CSS custom properties (--mouse-x, --mouse-y) that offset the blob transforms. Use lerp (linear interpolation) in a requestAnimationFrame loop to smooth the movement — direct mouse-to-position mapping feels jittery. Something like current += (target - current) * 0.08 per frame gives a nice lag. Just make sure to throttle the event handler and clean up the listener on component unmount.
They don't affect HTML content or crawlability. For Core Web Vitals, the main risk is CLS if blob divs shift layout on load — prevent this by using position: absolute inside a position: relative container with explicit height. Large blurred divs can affect LCP if they're the largest painted element; make sure actual content (hero heading, image) is what the browser identifies as LCP instead.