← Backgrounds
Petals
<PetalsBackground /> Cherry-blossom petals drifting on a breeze.
Your appContent sits on top of the background.
<PetalsBackground>
{children}
</PetalsBackground>Props and theme tokens
| Prop | Type | Default | Description |
|---|---|---|---|
theme | Partial<PetalsTheme> | — | Colour overrides. Keys listed under theme tokens. |
paused | boolean | false | Freezes the animation. |
className | string | — | Applied to the root element. Size it like any block element. |
style | CSSProperties | — | Merged into the root element style, after theme variables. |
children | ReactNode | — | Your content. The background paints behind it. |
| Theme key | CSS variable | Default |
|---|---|---|
baseTop | --base-top | #fff7f3 |
baseBottom | --base-bottom | #fde3ea |
petal1 | --petal-1 | #f6a9be |
petal2 | --petal-2 | #fbcfdc |
glow | --glow | #ffffff |
branch | --branch | #8a5a52 |
blossom | --blossom | #f8bed0 |
Source files
Copy into components/backgrounds/. The shared files are needed once for all backgrounds.
// <PetalsBackground>: cherry-blossom petals drifting on a breeze, with a swaying branch
// in the corner and soft bokeh. Animated.
import type { CSSProperties } from 'react';
import { BackgroundFrame, backgroundStyles as base, r2, seeded, type BackgroundProps } from './background';
import styles from './petals-background.module.css';
export const petalsTheme = {
baseTop: '#fff7f3',
baseBottom: '#fde3ea',
petal1: '#f6a9be',
petal2: '#fbcfdc',
glow: '#ffffff',
branch: '#8a5a52',
blossom: '#f8bed0',
};
export type PetalsTheme = typeof petalsTheme;
// Heart-shaped petal with a notch at the tip.
const PETAL = 'M0 8 C-6 4 -7 -3 -3 -7 C-1.5 -8.5 -0.5 -7.5 0 -6 C0.5 -7.5 1.5 -8.5 3 -7 C7 -3 6 4 0 8 Z';
const rand = seeded(57);
const PETALS = Array.from({ length: 36 }, (_, i) => ({
// Start up to 25% past the right edge so the breeze carries petals across all of it.
x: r2(rand() * 125),
y: r2(rand() * 100),
size: r2(10 + rand() * 9),
fall: r2(11 + rand() * 9),
delay: r2(rand() * 20),
spin: r2(5 + rand() * 7) * (rand() > 0.5 ? 1 : -1),
flutter: r2(0.8 + rand() * 1.2),
color: i % 3 === 0 ? 'var(--petal-1)' : 'var(--petal-2)',
}));
const BOKEH = Array.from({ length: 7 }, () => ({
x: r2(rand() * 90),
y: r2(rand() * 85),
size: r2(60 + rand() * 90),
dur: r2(8 + rand() * 8),
delay: r2(rand() * 8),
}));
function Blossom({ x, y, r }: { x: number; y: number; r: number }) {
return (
<g transform={`translate(${x} ${y})`}>
{[0, 72, 144, 216, 288].map(a => (
<ellipse key={a} cx="0" cy={-r * 0.55} rx={r * 0.42} ry={r * 0.55} transform={`rotate(${a})`} fill="var(--blossom)" />
))}
<circle r={r * 0.22} fill="#f28aa8" />
</g>
);
}
/** Cherry-blossom petals drifting on a breeze. */
export function PetalsBackground(props: BackgroundProps<PetalsTheme>) {
return (
<BackgroundFrame
{...props}
defaultTheme={petalsTheme}
layer={
<>
<div className={styles.base} />
{BOKEH.map((b, i) => (
<div
key={i}
className={styles.bokeh}
style={{ left: `${b.x}%`, top: `${b.y}%`, width: b.size, height: b.size, animationDuration: `${b.dur}s`, animationDelay: `${-b.delay}s` }}
/>
))}
{PETALS.map((p, i) => (
<div
key={i}
className={`${base.particle} ${styles.fall}`}
style={{ left: `${p.x}%`, '--y': `${p.y}%`, animationDuration: `${p.fall}s`, animationDelay: `${-p.delay}s` } as CSSProperties}
>
<span
className={styles.spin}
style={{ animationDuration: `${Math.abs(p.spin)}s`, animationDirection: p.spin < 0 ? 'reverse' : 'normal' }}
>
<span className={styles.flutter} style={{ animationDuration: `${p.flutter}s` }}>
<svg width={p.size} height={p.size} viewBox="-8 -9 16 18" style={{ display: 'block' }}>
<path d={PETAL} fill={p.color} />
<path d="M0 6 V-3" stroke="#fff" strokeOpacity="0.5" strokeWidth="0.8" strokeLinecap="round" />
</svg>
</span>
</span>
</div>
))}
<svg className={styles.branch} viewBox="0 0 230 150">
<path
d="M232 4 C190 14 160 30 128 58 C104 80 80 92 40 100 M150 40 C150 62 162 78 176 92 M100 76 C92 96 96 116 108 132"
fill="none"
stroke="var(--branch)"
strokeWidth="5"
strokeLinecap="round"
/>
<Blossom x={42} y={100} r={11} />
<Blossom x={76} y={88} r={9} />
<Blossom x={128} y={58} r={10} />
<Blossom x={176} y={92} r={12} />
<Blossom x={108} y={132} r={9} />
<Blossom x={190} y={22} r={10} />
<circle cx="60" cy="96" r="4" fill="var(--blossom)" />
<circle cx="158" cy="72" r="3.5" fill="var(--blossom)" />
</svg>
</>
}
/>
);
}
.base {
position: absolute;
inset: 0;
background: linear-gradient(170deg, var(--base-top), var(--base-bottom));
}
/* Petals ride a breeze: down the full height and a fifth of the width to the left. */
.fall {
animation: fall linear infinite;
will-change: transform;
}
.spin { display: block; animation: spin linear infinite; }
.flutter { display: block; animation: flutter ease-in-out infinite alternate; }
.bokeh {
position: absolute;
border-radius: 50%;
background: radial-gradient(circle, var(--glow) 0%, transparent 70%);
opacity: 0.55;
animation: float ease-in-out infinite alternate;
}
/* The branch hangs from the top-right corner and sways from its base. */
.branch {
position: absolute;
top: -6px;
right: -10px;
width: 230px;
transform-origin: 100% 0;
animation: sway 7s ease-in-out infinite alternate;
}
@keyframes fall {
from { transform: translate(0, -30px); }
to { transform: translate(-20cqw, calc(100cqh + 30px)); }
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes flutter { from { transform: scaleX(1); } to { transform: scaleX(0.25); } }
@keyframes float { from { transform: translate(0, 0); } to { transform: translate(24px, -18px); } }
@keyframes sway { from { transform: rotate(-2deg); } to { transform: rotate(1.5deg); } }
// Shared frame for every background: theming, layering and pausing.
// Backgrounds are pure server components: no client JavaScript.
import type { CSSProperties, ReactNode } from 'react';
import styles from './background.module.css';
export { styles as backgroundStyles };
/** Deterministic PRNG so particles render identically on server and client. */
export function seeded(seed: number): () => number {
return () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Round for stable, compact inline styles. */
export const r2 = (n: number) => Math.round(n * 100) / 100;
/** Props every background accepts. `T` is the background's theme (colour tokens). */
export type BackgroundProps<T> = {
/** Override any of the background's colours. */
theme?: Partial<T>;
/** Freeze animated backgrounds. Static backgrounds ignore it. */
paused?: boolean;
className?: string;
style?: CSSProperties;
/** Your content. The background paints behind it. */
children?: ReactNode;
};
// baseColor → --base-color, blob1 → --blob-1
const toVar = (key: string) => `--${key.replace(/([A-Z])/g, '-$1').replace(/(\d+)/g, '-$1').toLowerCase()}`;
type BackgroundFrameProps<T extends Record<string, string>> = BackgroundProps<T> & {
defaultTheme: T;
/** The texture itself, rendered in an absolutely positioned layer behind the children. */
layer: ReactNode;
};
export function BackgroundFrame<T extends Record<string, string>>({
defaultTheme,
layer,
theme,
paused,
className,
style,
children,
}: BackgroundFrameProps<T>) {
const vars: Record<string, string> = {};
for (const [key, value] of Object.entries({ ...defaultTheme, ...theme })) {
if (value) vars[toVar(key)] = value;
}
const classes = [styles.root, paused && styles.paused, className].filter(Boolean).join(' ');
return (
<div className={classes} style={{ ...vars, ...style } as CSSProperties}>
<div className={styles.layer} aria-hidden="true">
{layer}
</div>
{children}
</div>
);
}
/* The root establishes a stacking context so the layer sits behind children
without escaping behind the page. */
.root {
position: relative;
isolation: isolate;
}
.layer {
position: absolute;
inset: 0;
z-index: -1;
overflow: hidden;
pointer-events: none;
/* Lets particles move by the layer's own size: 100cqh is its full height, 100cqw its width. */
container-type: size;
}
/* A particle starts just above the layer at a random horizontal position. */
.particle {
position: absolute;
top: 0;
}
.paused,
.paused * { animation-play-state: paused !important; }
@media (prefers-reduced-motion: reduce) {
.layer,
.layer * { animation: none !important; }
/* Without motion, particles rest at a scattered position instead of the top edge. */
.particle { top: var(--y, 0); }
}