← Backgrounds

Petals

<PetalsBackground /> Cherry-blossom petals drifting on a breeze.

Animated · Source · Markdown

Your appContent sits on top of the background.
Theme
Usage
<PetalsBackground>
  {children}
</PetalsBackground>
Props and theme tokens
PropTypeDefaultDescription
themePartial<PetalsTheme>—Colour overrides. Keys listed under theme tokens.
pausedbooleanfalseFreezes the animation.
classNamestring—Applied to the root element. Size it like any block element.
styleCSSProperties—Merged into the root element style, after theme variables.
childrenReactNode—Your content. The background paints behind it.
Theme keyCSS variableDefault
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.

components/backgrounds/petals-background.tsx
// <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>
        </>
      }
    />
  );
}
components/backgrounds/petals-background.module.css
.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); } }
components/backgrounds/background.tsx
// 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>
  );
}
components/backgrounds/background.module.css
/* 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); }
}