Cinder
A falling-spark background: steep, hot streaks decelerate and die mid-air in an extinguish flicker, with the occasional spark forking into a second briefer ember. Pure CSS, seeded PRNG for deterministic spawn layout, honors prefers-reduced-motion.
cssfree
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface CinderProps extends React.ComponentPropsWithoutRef<"div"> {
/** Number of spark streaks in the field. Clamped 6–80. Default 28. */
density?: number;
/**
* Fall angle in degrees, measured from vertical (0 = straight down,
* positive tilts right). Cinder falls steep by default. Default 18.
*/
angle?: number;
/** Fall speed multiplier. 1 = default, 2 = twice as fast. Default 1. */
speed?: number;
/** Streak colors, cycled across the field. Defaults to a forge palette. */
colors?: string[];
/** Freeze the shower at its current frame. */
paused?: boolean;
/** Deterministic seed for spawn layout — same seed always renders the same field. */
seed?: number;
}
interface Spark {
key: string;
left: number; // vw%
top: number; // vh%
length: number; // px
duration: number; // s
delay: number; // s, always negative — phases the streak mid-cycle
color: string;
isChild: boolean;
}
/** Deterministic mulberry32 PRNG — same seed always produces the same sequence. */
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function buildField(density: number, colors: string[], seed: number): Spark[] {
const count = Math.min(80, Math.max(6, Math.round(density)));
const rand = mulberry32(seed);
const sparks: Spark[] = [];
for (let i = 0; i < count; i++) {
const duration = 1.6 + rand() * 1.8; // 1.6–3.4s per pass
const spark: Spark = {
key: `s${i}`,
left: rand() * 100,
top: rand() * -60, // stagger start heights above the field
length: 26 + rand() * 46,
duration,
// Negative delay = phase offset. Every streak starts mid-cycle so the
// field reads as an already-falling shower from first paint, and a
// paused/reduced-motion frame freezes on a naturally varied spread
// instead of a blank or bunched-up field.
delay: -rand() * duration,
color: colors[i % colors.length],
isChild: false,
};
sparks.push(spark);
// Roughly one in eight sparks pops into a second, briefer child spark
// shortly after — a small forked ember rather than a uniform shower.
if (rand() < 0.125) {
const childDuration = duration * (0.45 + rand() * 0.2);
sparks.push({
key: `s${i}c`,
left: spark.left + (rand() - 0.5) * 6,
top: spark.top + 12 + rand() * 10,
length: spark.length * 0.55,
duration: childDuration,
delay: spark.delay + duration * 0.35,
color: colors[(i + 1) % colors.length],
isChild: true,
});
}
}
return sparks;
}
const DEFAULT_COLORS = ["#ffd8a8", "#ff6b35", "#c1121f"];
/**
* Cinder — a falling-spark background. Steep, hot streaks decelerate and die
* in an extinguish flicker mid-air rather than exiting the frame, with the
* occasional spark forking into a second briefer ember. Pure CSS animation
* driven by a seeded PRNG so spawn layout is deterministic (no hydration
* mismatch) — same `seed` always renders the same field.
*/
export function Cinder({
density = 28,
angle = 18,
speed = 1,
colors = DEFAULT_COLORS,
paused = false,
seed = 1,
className,
style,
...props
}: CinderProps) {
const sparks = React.useMemo(
() => buildField(density, colors, seed),
// colors is typically a stable literal/default; re-seed the field when its
// contents actually change by keying off length + join rather than identity.
// eslint-disable-next-line react-hooks/exhaustive-deps
[density, seed, colors.join(",")]
);
const speedFactor = Math.max(speed, 0.01);
return (
<div
aria-hidden
data-crucible="cinder"
className={cn("absolute inset-0 -z-10 overflow-hidden", className)}
style={
{
"--cinder-angle": `${angle}deg`,
"--cinder-play": paused ? "paused" : "running",
...style,
} as React.CSSProperties
}
{...props}
>
<style>{`
@keyframes crucible-cinder-fall {
0% {
transform: translateY(0) rotate(var(--cinder-angle));
opacity: 0;
}
12% { opacity: 1; }
70% { opacity: 0.9; }
82% { opacity: 0.35; }
88% { opacity: 0.8; }
94% { opacity: 0.1; }
100% {
transform: translateY(64vh) rotate(var(--cinder-angle));
opacity: 0;
}
}
[data-crucible="cinder"] .cinder-spark {
animation-name: crucible-cinder-fall;
animation-timing-function: cubic-bezier(0.3, 0, 0.65, 1);
animation-iteration-count: infinite;
animation-play-state: var(--cinder-play);
will-change: transform, opacity;
}
@media (prefers-reduced-motion: reduce) {
[data-crucible="cinder"] .cinder-spark { animation-play-state: paused; }
}
`}</style>
{sparks.map((s) => (
<span
key={s.key}
className="cinder-spark absolute top-0 left-0 block rounded-full"
style={{
left: `${s.left}%`,
top: `${s.top}%`,
width: s.isChild ? 1.5 : 2,
height: s.length,
background: `linear-gradient(to bottom, ${s.color}, transparent)`,
boxShadow: `0 0 ${s.isChild ? 4 : 7}px ${s.color}`,
animationDuration: `${s.duration / speedFactor}s`,
animationDelay: `${s.delay / speedFactor}s`,
}}
/>
))}
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/cinderHonors prefers-reduced-motion with a designed static fallback, and passes className through.