Skip to content

Commit ff830e8

Browse files
authored
Merge pull request #60 from sumitsahoo/dev
feat: add AuroraBackground component for animated backdrop and update…
2 parents d436487 + 13f64f3 commit ff830e8

3 files changed

Lines changed: 279 additions & 182 deletions

File tree

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
/**
2+
* Aurora — six animated, blurred liquid-drop blobs that drift across the
3+
* viewport. Self-contained: drop it inside any container — the blobs
4+
* paint at z-index: 0 in the parent's stacking context, so any sibling
5+
* content with explicit z-index >= 1 (or that comes after this in the
6+
* tree, with no z) renders above them.
7+
*
8+
* Defaults match the CloakResume onboarding palette and a `multiply`
9+
* blend (good against a light background). Override via props for
10+
* other tools / dark surfaces.
11+
*
12+
* Honors prefers-reduced-motion. On narrow screens the two smallest
13+
* blobs drop out and the blur radius shrinks — fullscreen blur is the
14+
* single most expensive paint operation on mobile GPUs, so this keeps
15+
* scrolling smooth without changing the silhouette story noticeably.
16+
*/
17+
18+
import type { CSSProperties } from "react";
19+
20+
type BlendMode =
21+
| "multiply"
22+
| "screen"
23+
| "overlay"
24+
| "lighten"
25+
| "darken"
26+
| "soft-light"
27+
| "hard-light"
28+
| "color-dodge"
29+
| "color-burn"
30+
| "normal";
31+
32+
const DEFAULT_COLORS: readonly [string, string, string, string, string, string] = [
33+
"#2563eb",
34+
"#7c3aed",
35+
"#db2777",
36+
"#ea580c",
37+
"#0891b2",
38+
"#059669",
39+
];
40+
41+
interface Props {
42+
/** Six blob fills. Defaults to the CloakResume palette. */
43+
colors?: readonly [string, string, string, string, string, string];
44+
/**
45+
* `mix-blend-mode` for the blobs. `multiply` tints a light backdrop
46+
* with the blob hue; `screen` glows against a dark backdrop. Leave
47+
* undefined to inherit `--aurora-blend` from the surrounding CSS
48+
* (defaults to `multiply` if no token is set).
49+
*/
50+
blendMode?: BlendMode;
51+
/** Per-blob base opacity. The breathing animation oscillates ±30% around this. Defaults to 0.12. */
52+
opacity?: number;
53+
className?: string;
54+
}
55+
56+
/* ────────────────────────────────────────────────────────────────
57+
* Blob configuration. Each entry drives a single `<div class="aurora-blob">`
58+
* via inline CSS custom properties (`--w`, `--bg`, `--morph`, `--flow`,
59+
* `--breathe-*`) plus direct `top/right/bottom/left` styles. The CSS
60+
* has a single generic `.aurora-blob` rule that consumes those vars —
61+
* no per-blob CSS rules.
62+
*
63+
* Sizes use clamp(min, vw, max) so blobs scale with the viewport while
64+
* staying readable on phones and bounded on desktop. `colorIndex` picks
65+
* from the `colors` prop. `hideMobile` opts a blob out of small screens
66+
* for the perf budget. Negative animation-delays decorrelate the loops
67+
* so the composite never visibly repeats.
68+
* ──────────────────────────────────────────────────────────────── */
69+
interface BlobConfig {
70+
size: string;
71+
pos: { top?: string; right?: string; bottom?: string; left?: string };
72+
colorIndex: number;
73+
morph: string;
74+
flow: string;
75+
breatheDur: string;
76+
breatheDelay: string;
77+
hideMobile: boolean;
78+
}
79+
80+
const BLOBS: readonly BlobConfig[] = [
81+
{
82+
size: "clamp(320px, 52vw, 720px)",
83+
pos: { top: "-8%", left: "-10%" },
84+
colorIndex: 0,
85+
morph: "aurora-morph-a 18s ease-in-out infinite",
86+
flow: "aurora-flow-a 50s linear infinite",
87+
breatheDur: "12s",
88+
breatheDelay: "-2s",
89+
hideMobile: false,
90+
},
91+
{
92+
size: "clamp(240px, 36vw, 520px)",
93+
pos: { top: "-5%", right: "-8%" },
94+
colorIndex: 1,
95+
morph: "aurora-morph-b 20s ease-in-out infinite -4s",
96+
flow: "aurora-flow-b 58s linear infinite -16s",
97+
breatheDur: "14s",
98+
breatheDelay: "-7s",
99+
hideMobile: false,
100+
},
101+
{
102+
size: "clamp(180px, 26vw, 380px)",
103+
pos: { top: "30%", left: "25%" },
104+
colorIndex: 2,
105+
morph: "aurora-morph-a 22s ease-in-out infinite -10s",
106+
flow: "aurora-flow-c 44s linear infinite -12s",
107+
breatheDur: "11s",
108+
breatheDelay: "-3s",
109+
hideMobile: true,
110+
},
111+
{
112+
size: "clamp(300px, 46vw, 660px)",
113+
pos: { bottom: "-10%", left: "-8%" },
114+
colorIndex: 3,
115+
morph: "aurora-morph-b 19s ease-in-out infinite -2s",
116+
flow: "aurora-flow-a 54s linear infinite -28s",
117+
breatheDur: "13s",
118+
breatheDelay: "-1s",
119+
hideMobile: false,
120+
},
121+
{
122+
size: "clamp(160px, 22vw, 320px)",
123+
pos: { bottom: "-6%", right: "-10%" },
124+
colorIndex: 4,
125+
morph: "aurora-morph-a 21s ease-in-out infinite -8s",
126+
flow: "aurora-flow-b 62s linear infinite -34s",
127+
breatheDur: "10s",
128+
breatheDelay: "-5s",
129+
hideMobile: true,
130+
},
131+
{
132+
size: "clamp(220px, 32vw, 460px)",
133+
pos: { top: "18%", right: "12%" },
134+
colorIndex: 5,
135+
morph: "aurora-morph-b 17s ease-in-out infinite -12s",
136+
flow: "aurora-flow-c 48s linear infinite -6s",
137+
breatheDur: "15s",
138+
breatheDelay: "-9s",
139+
hideMobile: false,
140+
},
141+
];
142+
143+
/**
144+
* Stylesheet kept alongside the component so the effect ships as a
145+
* single file. Rendered as a `<style precedence>` tag — React 19
146+
* hoists it into `<head>` and dedupes by precedence key, so multiple
147+
* instances of `<AuroraBackground />` (or other importers) share one
148+
* stylesheet rather than spraying duplicates into the DOM.
149+
*
150+
* One generic `.aurora-blob` rule reads per-blob inline custom
151+
* properties; per-blob style rules don't exist. Keep static.
152+
*/
153+
const STYLESHEET = `
154+
@keyframes aurora-morph-a {
155+
0%, 100% { border-radius: 70% 30% 50% 50% / 50% 60% 40% 50%; }
156+
20% { border-radius: 30% 70% 70% 30% / 70% 30% 70% 30%; }
157+
40% { border-radius: 50% 50% 20% 80% / 20% 80% 25% 75%; }
158+
60% { border-radius: 80% 20% 80% 20% / 65% 35% 70% 30%; }
159+
80% { border-radius: 25% 75% 35% 65% / 75% 25% 65% 35%; }
160+
}
161+
@keyframes aurora-morph-b {
162+
0%, 100% { border-radius: 40% 60% 70% 30% / 25% 75% 30% 70%; }
163+
25% { border-radius: 80% 20% 30% 70% / 50% 50% 80% 20%; }
164+
50% { border-radius: 25% 75% 75% 25% / 80% 20% 30% 70%; }
165+
75% { border-radius: 60% 40% 30% 70% / 35% 65% 80% 20%; }
166+
}
167+
@keyframes aurora-flow-a {
168+
0%, 100% { transform: translate(0, 0) rotate(0deg) scale(1); }
169+
25% { transform: translate(55vw, 30vh) rotate(80deg) scale(1.2); }
170+
50% { transform: translate(40vw, 65vh) rotate(180deg) scale(0.85); }
171+
75% { transform: translate(-15vw, 45vh) rotate(280deg) scale(1.1); }
172+
}
173+
@keyframes aurora-flow-b {
174+
0%, 100% { transform: translate(0, 0) rotate(0deg) scale(1); }
175+
33% { transform: translate(-50vw, 35vh) rotate(-110deg) scale(0.8); }
176+
66% { transform: translate(-25vw, 70vh) rotate(-220deg) scale(1.25); }
177+
}
178+
@keyframes aurora-flow-c {
179+
0%, 100% { transform: translate(0, 0) rotate(0deg) scale(1); }
180+
20% { transform: translate(30vw, -35vh) rotate(80deg) scale(1.3); }
181+
50% { transform: translate(60vw, 25vh) rotate(180deg) scale(0.85); }
182+
80% { transform: translate(-20vw, 55vh) rotate(290deg) scale(1.05); }
183+
}
184+
/* Subtle opacity pulse — each blob runs at its own period so the
185+
composite never lines up. The keyframes scale the base opacity
186+
(set via --aurora-opacity) so the prop still controls overall
187+
intensity. */
188+
@keyframes aurora-breathe {
189+
0%, 100% { opacity: calc(var(--aurora-opacity, 0.12) * 0.7); }
190+
50% { opacity: calc(var(--aurora-opacity, 0.12) * 1.3); }
191+
}
192+
.aurora-blob {
193+
position: fixed;
194+
pointer-events: none;
195+
/* z-index: 0 (rather than -1) so the component works whether or not
196+
the parent establishes a stacking context. Sibling content with
197+
explicit z-index >= 1 paints above; siblings with no z paint above
198+
by tree order (since the blobs render first in this component). */
199+
z-index: 0;
200+
width: var(--w);
201+
height: var(--w);
202+
background: var(--bg);
203+
filter: blur(60px);
204+
mix-blend-mode: var(--aurora-blend, multiply);
205+
opacity: var(--aurora-opacity, 0.12);
206+
will-change: border-radius, transform, opacity;
207+
animation:
208+
var(--morph),
209+
var(--flow),
210+
aurora-breathe var(--breathe-dur, 12s) ease-in-out infinite var(--breathe-delay, 0s);
211+
}
212+
/* Mobile budget: shrink the blur (the heaviest GPU op) and drop the
213+
two smallest blobs that mostly add density rather than silhouette. */
214+
@media (max-width: 640px) {
215+
.aurora-blob { filter: blur(36px); }
216+
.aurora-blob-mobile-hide { display: none; }
217+
}
218+
@media (prefers-reduced-motion: reduce) {
219+
.aurora-blob { animation: none; }
220+
}
221+
`;
222+
223+
export function AuroraBackground({
224+
colors = DEFAULT_COLORS,
225+
blendMode,
226+
opacity,
227+
className,
228+
}: Props) {
229+
const rootStyle: CSSProperties = {
230+
...(blendMode ? { "--aurora-blend": blendMode } : null),
231+
...(opacity != null ? { "--aurora-opacity": String(opacity) } : null),
232+
} as CSSProperties;
233+
234+
return (
235+
<div aria-hidden="true" className={`aurora-root ${className ?? ""}`} style={rootStyle}>
236+
<style precedence="aurora-background">{STYLESHEET}</style>
237+
{BLOBS.map((b) => {
238+
const blobStyle: CSSProperties = {
239+
...b.pos,
240+
"--w": b.size,
241+
"--bg": colors[b.colorIndex],
242+
"--morph": b.morph,
243+
"--flow": b.flow,
244+
"--breathe-dur": b.breatheDur,
245+
"--breathe-delay": b.breatheDelay,
246+
} as CSSProperties;
247+
return (
248+
<div
249+
key={b.colorIndex}
250+
className={`aurora-blob${b.hideMobile ? " aurora-blob-mobile-hide" : ""}`}
251+
style={blobStyle}
252+
/>
253+
);
254+
})}
255+
</div>
256+
);
257+
}

src/components/Layout.tsx

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import { ChevronLeft, ShieldCheck } from "lucide-react";
1111
import type { ReactNode } from "react";
12+
import { AuroraBackground } from "./AuroraBackground";
1213

1314
declare const __APP_VERSION__: string;
1415

@@ -38,15 +39,10 @@ function GithubMark({ className = "" }: { className?: string }) {
3839
export function Layout({ children, onHome, showBack, onPrivacy }: LayoutProps) {
3940
return (
4041
<div className="relative min-h-screen bg-linear-to-br from-slate-50 via-white to-primary-50/40 dark:from-dark-bg dark:via-dark-bg dark:to-dark-surface/60 flex flex-col">
41-
{/* Aurora — morphing liquid-drop blobs. Six fixed divs each run
42-
two uncorrelated loops (border-radius morph + transform drift)
43-
so silhouettes feel organic. mix-blend-mode is themed in CSS. */}
44-
<div aria-hidden="true" className="aurora-blob aurora-blob-1" />
45-
<div aria-hidden="true" className="aurora-blob aurora-blob-2" />
46-
<div aria-hidden="true" className="aurora-blob aurora-blob-3" />
47-
<div aria-hidden="true" className="aurora-blob aurora-blob-4" />
48-
<div aria-hidden="true" className="aurora-blob aurora-blob-5" />
49-
<div aria-hidden="true" className="aurora-blob aurora-blob-6" />
42+
{/* Aurora backdrop — self-contained component. mix-blend-mode is
43+
themed via the surrounding `--aurora-blend` token (light:
44+
multiply, dark: screen) defined in index.css. */}
45+
<AuroraBackground />
5046

5147
<header className="relative z-50 bg-white/85 dark:bg-dark-surface/85 backdrop-blur-md border-b border-slate-200/80 dark:border-dark-border sticky top-0 shadow-sm shadow-slate-100/50 dark:shadow-black/20">
5248
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-3 flex items-center gap-3">
@@ -104,7 +100,15 @@ export function Layout({ children, onHome, showBack, onPrivacy }: LayoutProps) {
104100
{children}
105101
</main>
106102

107-
<footer className="relative z-10 border-t border-slate-200/60 dark:border-dark-border bg-[color-mix(in_oklab,white_55%,transparent)] dark:bg-[color-mix(in_oklab,var(--color-dark-surface)_55%,transparent)] backdrop-blur-sm">
103+
{/* Footer bg bumped to ~92% opaque (was 55%) so the orange aurora
104+
blob anchored at the bottom-left can't bleed through into iOS
105+
Safari's bottom-toolbar tint sampling. `safe-area-inset-bottom`
106+
extends the painted area into the home-indicator zone so the
107+
toolbar always samples the footer's surface color. */}
108+
<footer
109+
className="relative z-10 border-t border-slate-200/60 dark:border-dark-border bg-[color-mix(in_oklab,white_92%,transparent)] dark:bg-[color-mix(in_oklab,var(--color-dark-surface)_92%,transparent)] backdrop-blur-md"
110+
style={{ paddingBottom: "env(safe-area-inset-bottom, 0px)" }}
111+
>
108112
<div className="max-w-6xl mx-auto px-4 sm:px-6 pt-10 pb-7 sm:pt-12 sm:pb-8">
109113
{/* Top row: brand + privacy link */}
110114
<div className="flex flex-col sm:flex-row sm:items-start gap-6 sm:gap-10">

0 commit comments

Comments
 (0)