Feedback & Data
StreakFlame
The streak is a clay lamp in a stone niche. The flame burns higher the longer the streak, sinks low on a day not yet done and takes the wind from your pointer. Light today and it leaps; lose the streak and it dies to a thread of smoke.
Download
- React
- Tailwind CSS v4
- motion
components/ui/streak-flame.tsx
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { AnimatePresence, motion, useAnimationFrame, useInView, useReducedMotion, useSpring } from "motion/react";
type StreakFlameProps = {
/** Days in a row (controlled). */
streak?: number;
/** Days in a row at start (uncontrolled). */
defaultStreak?: number;
/** Today is done (controlled). */
lit?: boolean;
/** Today is done at start (uncontrolled). */
defaultLit?: boolean;
/** Word after the count. */
unit?: string;
/** Called with the new streak when today is lit. */
onLight?: (streak: number) => void;
/** Set the width here. Everything scales with it. */
className?: string;
};
const WICK = { x: 319, y: 170 };
const LAYERS = [
{ scale: 1, fill: "o", seed: 0 },
{ scale: 0.7, fill: "y", seed: 1.7 },
{ scale: 0.4, fill: "w", seed: 3.1 },
];
const CHIPS = [0, 1, 2, 3, 4, 5, 6];
// One tongue of flame, drawn fresh each frame from a few waves.
const tongue = (h: number, w: number, t: number, lean: number, seed: number) => {
const left: string[] = [];
const right: string[] = [];
const stretch = h * (1 + Math.sin(t * 8 + seed) * 0.05 + Math.sin(t * 13.7 + seed) * 0.03);
for (let i = 0; i <= 12; i++) {
const s = i / 12;
const half = w * Math.pow(Math.sin(Math.PI * Math.pow(s, 0.62)), 0.85) * (1 - s * 0.25);
const sway = lean * s * s * h + Math.sin(t * 6.3 + s * 4.6 + seed) * s * w * 0.3 + Math.sin(t * 10 + s * 8 + seed) * s * s * w * 0.14;
const y = WICK.y + 4 - s * stretch;
left.push(`${(WICK.x - half + sway).toFixed(1)} ${y.toFixed(1)}`);
right.unshift(`${(WICK.x + half + sway).toFixed(1)} ${y.toFixed(1)}`);
}
return `M${left.join("L")}L${right.join("L")}Z`;
};
/* StreakFlame: the streak is a clay lamp in a stone niche. The flame burns higher the longer the streak, sinks low on a day not yet done, and takes the wind from your pointer. Light today and it leaps; lose the streak and it dies to a thread of smoke. */
export function StreakFlame({ streak, defaultStreak = 12, lit, defaultLit = false, unit = "day streak", onLight, className = "w-full max-w-lg" }: StreakFlameProps) {
const reduce = useReducedMotion();
const id = useId().replace(/:/g, "");
const box = useRef<HTMLDivElement>(null);
const inView = useInView(box, { amount: 0.1 });
const [innerStreak, setInnerStreak] = useState(defaultStreak);
const [innerLit, setInnerLit] = useState(defaultLit);
const [startLit] = useState(lit ?? defaultLit);
const count = streak ?? innerStreak;
const done = lit ?? innerLit;
const dead = count === 0 && !done;
const paths = useRef<(SVGPathElement | null)[]>([]);
const glow = useRef<SVGCircleElement>(null);
const wind = useRef({ now: 0, goal: 0 });
const full = 44 + 72 * (1 - Math.exp(-count / 16));
const goal = dead ? 0 : done ? full : full * 0.42;
const size = useSpring(goal, reduce ? { stiffness: 10000, damping: 500 } : { stiffness: 120, damping: 7 });
useEffect(() => size.set(goal), [size, goal]);
useAnimationFrame((time, delta) => {
if (!inView) return;
const t = reduce ? 1 : time / 1000;
const h = Math.max(0, size.get());
const w = wind.current;
w.now += (w.goal - w.now) * Math.min(1, delta / 160);
paths.current.forEach((el, i) => {
if (!el) return;
const l = LAYERS[i];
el.setAttribute("d", h < 1 ? "" : tongue(h * l.scale, Math.max(3, h * 0.2) * l.scale, t, w.now, l.seed));
});
const c = glow.current;
if (c) {
c.setAttribute("r", (70 + h * 1.5 + Math.sin(t * 9) * 3).toFixed(1));
c.setAttribute("opacity", Math.min(1, h / 60).toFixed(2));
}
});
const light = () => {
if (done) return;
setInnerLit(true);
setInnerStreak(count + 1);
onLight?.(count + 1);
};
const past = Math.min(6, Math.max(0, count - (done ? 1 : 0)));
const mono = "ui-monospace, Consolas, monospace";
return (
<div
ref={box}
className={`@container relative select-none ${className}`}
onPointerMove={(e) => {
const b = box.current?.getBoundingClientRect();
if (b) wind.current.goal = Math.max(-0.5, Math.min(0.5, -((e.clientX - b.left) / b.width - 0.69) * 0.9));
}}
onPointerLeave={() => {
wind.current.goal = 0;
}}
>
<svg viewBox="0 0 460 270" className="block h-auto w-full" role="img" aria-label={`${count} ${unit}${done ? ", today is done" : ", today is not done yet"}`}>
<defs>
<linearGradient id={`${id}o`} x1="0" y1="1" x2="0" y2="0">
<stop offset="0" stopColor="#d9441a" />
<stop offset="0.45" stopColor="#f58a1f" />
<stop offset="1" stopColor="#ffb340" />
</linearGradient>
<linearGradient id={`${id}y`} x1="0" y1="1" x2="0" y2="0">
<stop offset="0" stopColor="#f59a24" />
<stop offset="1" stopColor="#ffe27a" />
</linearGradient>
<linearGradient id={`${id}w`} x1="0" y1="1" x2="0" y2="0">
<stop offset="0" stopColor="#7fb2ff" />
<stop offset="0.35" stopColor="#fff4c9" />
<stop offset="1" stopColor="#fffbe8" />
</linearGradient>
<radialGradient id={`${id}halo`} fx="0.5" fy="0.5">
<stop offset="0" stopColor="#ffa84a" stopOpacity="0.62" />
<stop offset="0.5" stopColor="#ff7a20" stopOpacity="0.16" />
<stop offset="1" stopColor="#ff7a20" stopOpacity="0" />
</radialGradient>
<linearGradient id={`${id}clay`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#c8693a" />
<stop offset="1" stopColor="#6d2e16" />
</linearGradient>
<clipPath id={`${id}wall`}>
<rect width="460" height="270" rx="12" />
</clipPath>
</defs>
<g clipPath={`url(#${id}wall)`}>
{/* Stone wall, cut into blocks. */}
<rect width="460" height="270" fill="#221c1a" />
{[0, 1, 2, 3, 4].map((r) => (
<g key={r}>
<line x1="0" x2="460" y1={r * 44 + 18} y2={r * 44 + 18} stroke="#0f0c0b" strokeWidth="2" />
{[0, 1, 2, 3].map((c) => (
<line key={c} x1={c * 140 + (r % 2) * 70 + 26} x2={c * 140 + (r % 2) * 70 + 26} y1={r * 44 - 26} y2={r * 44 + 18} stroke="#0f0c0b" strokeWidth="2" />
))}
</g>
))}
{/* Niche. */}
<path d="M226 224V110C226 40 412 40 412 110V224Z" fill="#120d0b" />
<path d="M226 224V110C226 40 412 40 412 110V224" fill="none" stroke="#3b312c" strokeWidth="5" />
<circle ref={glow} cx={WICK.x} cy={WICK.y - 30} r="90" fill={`url(#${id}halo)`} opacity="0" />
{/* Smoke when it dies. */}
{dead && !reduce && (
<motion.path
d={`M${WICK.x} ${WICK.y}c-12-20 14-30 0-52s12-30-2-54`}
fill="none"
stroke="#bdb6ae"
strokeWidth="3"
strokeLinecap="round"
initial={{ pathLength: 0, opacity: 0.7 }}
animate={{ pathLength: 1, opacity: 0 }}
transition={{ duration: 2.4, ease: "easeOut" }}
/>
)}
{LAYERS.map((l, i) => (
<path
key={l.fill}
ref={(el) => {
paths.current[i] = el;
}}
fill={`url(#${id}${l.fill})`}
style={{ mixBlendMode: i ? "normal" : "screen" }}
/>
))}
{/* Sparks when today is lit. */}
{done && !startLit && !reduce && (
<g key={count}>
{[-60, -35, -15, 10, 30, 55].map((a, i) => (
<motion.circle key={a} cx={WICK.x} cy={WICK.y - 30} r="2" fill="#ffd27a" initial={{ x: 0, y: 0, opacity: 1 }} animate={{ x: a * 0.9, y: -60 - i * 7, opacity: 0 }} transition={{ duration: 0.9, ease: "easeOut" }} />
))}
</g>
)}
{/* The lamp, on the sill. */}
<rect x="204" y="224" width="232" height="14" rx="2" fill="#4a403a" />
<rect x="204" y="224" width="232" height="3" fill="#6a5d55" />
<path d="M252 182C260 226 300 226 324 182C312 176 262 176 252 182Z" fill={`url(#${id}clay)`} />
<path d="M306 182C312 176 322 172 334 170C330 180 324 188 316 194Z" fill={`url(#${id}clay)`} />
<ellipse cx="288" cy="182" rx="35" ry="6" fill="#2f1a0c" />
<ellipse cx="288" cy="182.5" rx="31" ry="4" fill="#a8791f" opacity="0.8" />
<path d="M304 180Q312 176 320 171" fill="none" stroke="#cbb98f" strokeWidth="3.2" strokeLinecap="round" />
<circle cx={WICK.x + 0.5} cy={WICK.y + 1} r="2" fill={dead ? "#3b3331" : "#ff8a2b"} />
{/* The count, cut in the wall. */}
<AnimatePresence initial={false}>
<motion.text key={count} x="112" y="128" textAnchor="middle" fontSize="84" fontWeight="700" fontFamily={mono} fill="#efe3c6" initial={{ opacity: 0, y: 26 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -26 }} transition={{ duration: reduce ? 0 : 0.35 }}>
{count}
</motion.text>
</AnimatePresence>
<text x="112" y="154" textAnchor="middle" fontSize="15" letterSpacing="1" fontFamily={mono} fill="#b5a892">{unit}</text>
{/* This week: small lamps, the last is today. */}
{CHIPS.map((i) => {
const today = i === 6;
const on = today ? done : i >= 6 - past;
const x = 44 + i * 23;
return (
<g key={i}>
<path d={`M${x - 8} 186q8 11 16 0z`} fill={on ? "#c8693a" : "#4a403a"} />
{on && <path d={`M${x} 184c-4-5 0-8 0-12c3 4 4 8 0 12z`} fill="#ffb340" />}
{today && <line x1={x - 9} x2={x + 9} y1="197" y2="197" stroke="#efe3c6" strokeWidth="1.6" />}
</g>
);
})}
</g>
</svg>
<button
type="button"
onClick={light}
disabled={done}
className="absolute cursor-pointer rounded-[6px] font-mono font-semibold disabled:cursor-default"
style={{ left: "6.5%", top: "76%", width: "37%", height: "14%", fontSize: "clamp(10px, 3.3cqw, 15px)", color: done ? "#b5a892" : "#221c1a", background: done ? "transparent" : "linear-gradient(180deg, #f2c66b, #d39632)", boxShadow: done ? "inset 0 0 0 1px #4a403a" : "0 2px 0 #7c5a22" }}
>
{done ? "Lit for today" : "Light today"}
</button>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/streak-flame.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/streak-flame-demo.tsx
"use client"; import { useState } from "react"; import { StreakFlame } from "@/components/ui/streak-flame"; export default function StreakFlameExample() { const [streak, setStreak] = useState(12); const [lit, setLit] = useState(false); return ( <div className="@container-size relative grid h-full place-items-center overflow-hidden bg-[#141010] p-2"> <StreakFlame streak={streak} lit={lit} onLight={(next) => { setStreak(next); setLit(true); }} className="w-[min(165cqh,96cqw,560px)]" /> <button type="button" onClick={() => { setStreak(0); setLit(false); }} className="absolute bottom-[3cqh] left-1/2 -translate-x-1/2 cursor-pointer rounded px-2 py-0.5 font-mono text-[11px] text-[#b5a892] underline underline-offset-2" > Miss a day </button> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| streak | number | - | Controlled days in a row. 0 with lit false puts the flame out. |
| defaultStreak | number | 12 | Uncontrolled start count. |
| lit | boolean | - | Controlled: today is done. |
| defaultLit | boolean | false | Today is done at start. |
| unit | string | "day streak" | Word after the count. |
| onLight | (streak: number) => void | - | Called with the new streak when today is lit. |
| className | string | "w-full max-w-lg" | Set the width here. Everything scales with it. |