Translucent aurora curtains drawn as SVG bands whose edges follow layered sine waves, flowing continuously over a starry night sky with adjustable intensity, blur and tilt.
Preview
Aurora Ribbons
Translucent curtains of light over a quiet night sky.
Customize
10 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add aurora-ribbonsWrites components/ui/aurora-ribbons.tsx and components/styles/aurora-ribbons.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { AuroraRibbons } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <AuroraRibbons />6 );7}Props
Accessibility
The star field, glow and SVG ribbons are aria-hidden (the SVG is focusable=false) with pointer-events: none. The requestAnimationFrame loop runs only while visible and speed > 0; prefers-reduced-motion shows a static frame with no star twinkle.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import styles from "../styles/aurora-ribbons.module.css";5 6export interface AuroraRibbonsProps {7 /** Primary ribbon color */8 color1?: string;9 /** Secondary ribbon color */10 color2?: string;11 /** Tertiary ribbon color */12 color3?: string;13 /** Night-sky color behind the ribbons */14 backgroundColor?: string;15 /** Animation speed multiplier (1 = default flow, 0 = paused) */16 speed?: number;17 /** Ribbon brightness / opacity (0–1.5) */18 intensity?: number;19 /** Blur applied to the ribbons, in px */20 blur?: number;21 /** Tilt of the ribbon field in degrees */22 angle?: number;23 /** Height of the background in px */24 height?: number;25 /** Corner radius in px */26 borderRadius?: number;27 /** Foreground content rendered centered on top */28 children?: React.ReactNode;29 /** Optional className merged onto the root */30 className?: string;31 /** Inline styles merged onto the root */32 style?: React.CSSProperties;33}34 35const VB_W = 1200;36const VB_H = 500;37const SEGMENTS = 48;38 39/** Each ribbon: base line (fraction of height), curtain height, wave frequencies, phase, flow speed, color index */40const RIBBONS = [41 { base: 0.5, tall: 190, f1: 2.1, f2: 4.7, a1: 46, a2: 16, phase: 0.0, flow: 0.32, color: 0 },42 { base: 0.6, tall: 150, f1: 1.6, f2: 5.3, a1: 38, a2: 14, phase: 1.9, flow: 0.26, color: 1 },43 { base: 0.42, tall: 120, f1: 2.7, f2: 3.9, a1: 30, a2: 18, phase: 3.4, flow: 0.38, color: 2 },44 { base: 0.66, tall: 110, f1: 1.3, f2: 6.1, a1: 26, a2: 10, phase: 4.6, flow: 0.22, color: 0 },45 { base: 0.36, tall: 90, f1: 3.1, f2: 4.2, a1: 22, a2: 12, phase: 5.8, flow: 0.42, color: 1 },46];47 48const rand = (n: number) => {49 const x = Math.sin(n * 9301 + 49297) * 233280;50 return x - Math.floor(x);51};52 53const STARS = Array.from({ length: 46 }, (_, i) => ({54 x: rand(i) * 100,55 y: rand(i + 101) * 70,56 r: 0.6 + rand(i + 203) * 1.1,57 o: 0.25 + rand(i + 307) * 0.6,58 d: 2 + rand(i + 409) * 4,59}));60 61function ribbonPath(index: number, t: number): string {62 const r = RIBBONS[index];63 const baseY = r.base * VB_H;64 const top: string[] = [];65 const bottom: string[] = [];66 for (let s = 0; s <= SEGMENTS; s++) {67 const u = s / SEGMENTS;68 const x = -100 + u * (VB_W + 200);69 const y =70 baseY +71 Math.sin(u * r.f1 * Math.PI + t * r.flow + r.phase) * r.a1 +72 Math.sin(u * r.f2 * Math.PI - t * r.flow * 1.6 + r.phase * 0.7) * r.a2;73 const h =74 r.tall *75 (0.55 + 0.45 * Math.sin(u * 3.3 * Math.PI + t * r.flow * 0.8 + r.phase * 1.3)) *76 (0.75 + 0.25 * Math.sin(u * 7.1 * Math.PI - t * r.flow * 1.2));77 bottom.push(`${x.toFixed(1)},${y.toFixed(1)}`);78 top.push(`${x.toFixed(1)},${(y - h).toFixed(1)}`);79 }80 return `M${bottom.join(" L")} L${top.reverse().join(" L")} Z`;81}82 83function useReducedMotionPref() {84 const [reduced, setReduced] = React.useState(false);85 React.useEffect(() => {86 if (typeof window === "undefined" || !window.matchMedia) return;87 const mq = window.matchMedia("(prefers-reduced-motion: reduce)");88 const update = () => setReduced(mq.matches);89 update();90 mq.addEventListener("change", update);91 return () => mq.removeEventListener("change", update);92 }, []);93 return reduced;94}95 96function useInView(ref: React.RefObject<HTMLElement | null>) {97 const [inView, setInView] = React.useState(true);98 React.useEffect(() => {99 const el = ref.current;100 if (!el || typeof IntersectionObserver === "undefined") return;101 const io = new IntersectionObserver((entries) => {102 const entry = entries[entries.length - 1];103 if (entry) setInView(entry.isIntersecting);104 }, { rootMargin: "80px" });105 io.observe(el);106 return () => io.disconnect();107 }, [ref]);108 return inView;109}110 111export function AuroraRibbons({112 color1 = "#3dffa8",113 color2 = "#22d3ee",114 color3 = "#a855f7",115 backgroundColor = "#040714",116 speed = 1,117 intensity = 0.9,118 blur = 18,119 angle = -8,120 height = 360,121 borderRadius = 20,122 children,123 className = "",124 style,125}: AuroraRibbonsProps) {126 const rootRef = React.useRef<HTMLDivElement>(null);127 const pathRefs = React.useRef<Array<SVGPathElement | null>>([]);128 const timeRef = React.useRef(0);129 const reduced = useReducedMotionPref();130 const inView = useInView(rootRef);131 const uid = React.useId().replace(/[^a-zA-Z0-9_-]/g, "");132 const colors = [color1, color2, color3];133 const safeSpeed = Math.max(0, speed);134 const running = !reduced && safeSpeed > 0 && inView;135 136 React.useEffect(() => {137 if (!running) return;138 let raf = 0;139 let last = performance.now();140 const tick = (now: number) => {141 const dt = Math.min(0.05, (now - last) / 1000);142 last = now;143 timeRef.current += dt * safeSpeed;144 for (let i = 0; i < RIBBONS.length; i++) {145 const p = pathRefs.current[i];146 if (p) p.setAttribute("d", ribbonPath(i, timeRef.current));147 }148 raf = requestAnimationFrame(tick);149 };150 raf = requestAnimationFrame(tick);151 return () => cancelAnimationFrame(raf);152 }, [running, safeSpeed]);153 154 const styleVars = {155 "--mf-ar-bg": backgroundColor,156 "--mf-ar-blur": `${Math.max(0, blur)}px`,157 "--mf-ar-intensity": String(Math.max(0, Math.min(1.5, intensity))),158 "--mf-ar-angle": `${angle}deg`,159 "--mf-ar-glow": color1,160 "--mf-ar-state": running ? "running" : "paused",161 height: `${height}px`,162 borderRadius: `${borderRadius}px`,163 ...style,164 } as React.CSSProperties;165 166 return (167 <div ref={rootRef} style={styleVars} className={`${styles.root} ${className}`.trim()}>168 <div aria-hidden="true" className={styles.stars}>169 {STARS.map((s, i) => (170 <span171 key={i}172 className={styles.star}173 style={{174 left: `${s.x.toFixed(2)}%`,175 top: `${s.y.toFixed(2)}%`,176 width: `${s.r.toFixed(2)}px`,177 height: `${s.r.toFixed(2)}px`,178 opacity: Number(s.o.toFixed(2)),179 animationDuration: `${s.d.toFixed(2)}s`,180 }}181 />182 ))}183 </div>184 <div aria-hidden="true" className={styles.glow} />185 <svg186 aria-hidden="true"187 className={styles.ribbons}188 viewBox={`0 0 ${VB_W} ${VB_H}`}189 preserveAspectRatio="none"190 focusable="false"191 >192 <defs>193 {colors.map((c, i) => (194 <linearGradient key={i} id={`${uid}-ar-${i}`} x1="0" y1="0" x2="0" y2="1">195 <stop offset="0%" stopColor={c} stopOpacity="0" />196 <stop offset="50%" stopColor={c} stopOpacity="0.45" />197 <stop offset="88%" stopColor={c} stopOpacity="1" />198 <stop offset="100%" stopColor={c} stopOpacity="0.15" />199 </linearGradient>200 ))}201 </defs>202 {RIBBONS.map((r, i) => (203 <path204 key={i}205 ref={(el) => {206 pathRefs.current[i] = el;207 }}208 className={styles.ribbon}209 d={ribbonPath(i, 0)}210 fill={`url(#${uid}-ar-${r.color})`}211 style={{ opacity: i < 3 ? 1 : 0.6 }}212 />213 ))}214 </svg>215 <div aria-hidden="true" className={styles.horizon} />216 <div className={styles.content}>217 {children ?? (218 <div className={styles.copy}>219 <h3 className={styles.title}>Aurora Ribbons</h3>220 <p className={styles.subtitle}>Translucent curtains of light over a quiet night sky.</p>221 </div>222 )}223 </div>224 </div>225 );226}