Feedback

Celebrate

A brief burst of confetti from its content, for a rare moment worth marking such as a first deploy.

Built on
CSS animationsARIA live region
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
2.7 kB
Dependencies
React
Code
"use client";

import { useState } from "react";
import { Button } from "@/components/equip/button";
import { Celebrate } from "@/components/equip/celebrate";

export default function Demo() {
  const [bursts, setBursts] = useState(0);
  return (
    <Celebrate burstKey={bursts} message="Setup complete">
      <Button variant="accent" onClick={() => setBursts((n) => n + 1)}>
        {bursts > 0 ? "Setup complete" : "Finish setup"}
      </Button>
    </Celebrate>
  );
}

Examples

From the top of a card

The burst is keyed on the invoice number, so paying the same invoice twice in a session never celebrates twice.

Invoice 2291, Larder Ltd$4,800.00 due
Code
"use client";

import { useState } from "react";
import { Button } from "@/components/equip/button";
import { Celebrate } from "@/components/equip/celebrate";

export default function Example() {
  const [paid, setPaid] = useState<string | undefined>(undefined);
  return (
    <Celebrate
      burstKey={paid}
      origin="top"
      count={36}
      message="Invoice 2291 paid"
      style={{ display: "block", maxWidth: 320 }}
    >
      <div style={{ display: "grid", gap: 8, padding: 16, border: "1px solid var(--eq-line)", borderRadius: "var(--eq-radius-l)" }}>
        <span style={{ fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)" }}>Invoice 2291, Larder Ltd</span>
        <strong style={{ fontSize: "var(--eq-text-l)" }}>{paid ? "Paid in full" : "$4,800.00 due"}</strong>
        <Button variant={paid ? "outline" : "accent"} disabled={paid !== undefined} onClick={() => setPaid("inv-2291")}>
          {paid ? "Receipt sent" : "Mark as paid"}
        </Button>
      </div>
    </Celebrate>
  );
}

From a chosen point

The key changes only on the first deploy, so later deploys pass quietly.

acme-web is ready to ship
Code
"use client";

import { useState } from "react";
import { Button } from "@/components/equip/button";
import { Celebrate } from "@/components/equip/celebrate";

export default function Example() {
  const [deploys, setDeploys] = useState(0);
  return (
    <Celebrate burstKey={deploys > 0 ? "first-deploy" : undefined} origin={{ x: 90, y: 50 }} count={16} message="First deploy is live" style={{ display: "block", maxWidth: 360 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 12, padding: 12, border: "1px solid var(--eq-line)", borderRadius: "var(--eq-radius-l)" }}>
        <span style={{ flex: 1, fontSize: "var(--eq-text-s)" }}>
          {deploys > 0 ? "acme-web is live on eu-west-1" : "acme-web is ready to ship"}
        </span>
        <Button size="s" onClick={() => setDeploys((n) => n + 1)}>
          {deploys > 0 ? "Deploy again" : "Deploy"}
        </Button>
      </div>
    </Celebrate>
  );
}

Props

PropTypeDefaultWhat it does
burstKeynumber | stringnoneFires a burst whenever it changes after mount. Never fires on first render.
origin"center" | "top" | { x: number; y: number }"center"Where pieces fly from: the centre, the top edge, or a point in percent of the wrapper. "centre" is accepted too.
countnumber28Pieces per burst.
messagestringnoneAnnounced politely with each burst, and the only thing that happens when motion is off.
childrenReactNodenoneThe thing being celebrated.
...restComponentProps<"div">noneEvery native div attribute.

Anatomy

root.eq-celebrateWraps the content and anchors the burst.
status.eq-celebrate-statusVisually hidden polite live region for the message.
burst.eq-celebrate-burstHidden from assistive tech and from the pointer; removed when its last piece lands.
piece.eq-celebrate-pieceOne CSS-animated piece, coloured from five tokens.

Notes

  • Pieces use the accent alongside ink, focus, ok and warn: the one decorative use of the accent, because a celebration is the product's signature moment.
  • Each burst looks random but is seeded from its key and count, so nothing random happens during render.
  • The flight lasts three times the move duration, so the Still feel and reduced motion show no pieces at all; the message is still announced.
  • Pieces fly outside the wrapper, so an ancestor with overflow hidden will clip them.
  • Keep it for moments that happen once or rarely; a burst on every save stops meaning anything.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/celebrate.tsx135 lines
"use client";

import { useEffect, useRef, useState, type AnimationEvent, type ComponentProps, type CSSProperties } from "react";
import "./celebrate.css";

export type CelebrateProps = ComponentProps<"div"> & {
  /** Fires a burst whenever it changes after mount. Never fires on first render. */
  burstKey?: number | string;
  /** Where pieces fly from: the centre, the top edge, or a point in percent of the wrapper. */
  origin?: "center" | "centre" | "top" | { x: number; y: number };
  /** Pieces per burst. */
  count?: number;
  /** Announced politely with each burst, and the only thing shown when motion is off. */
  message?: string;
};

type Piece = { tone: number; dot: boolean; style: CSSProperties };
type Burst = { id: number; x: number; y: number; pieces: Piece[] };

/* A small seeded generator, so a burst looks random but is the same every time for its key. */
function seeded(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

function hash(text: string) {
  let h = 2166136261;
  for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619);
  return h >>> 0;
}

function makePieces(count: number, seed: number): Piece[] {
  const random = seeded(seed);
  const between = (min: number, max: number) => min + random() * (max - min);
  return Array.from({ length: count }, (_, i) => {
    /* Mostly upward, fanned out, so pieces rise, spread and fall back past the origin. */
    const angle = ((-90 + between(-68, 68)) * Math.PI) / 180;
    const distance = between(48, 112);
    const size = between(5, 9);
    return {
      tone: Math.floor(random() * 5),
      dot: i % 4 === 0,
      style: {
        "--_x": `${(Math.cos(angle) * distance).toFixed(1)}px`,
        "--_y": `${(Math.sin(angle) * distance).toFixed(1)}px`,
        "--_fall": `${between(56, 120).toFixed(1)}px`,
        "--_spin": `${(between(240, 720) * (random() < 0.5 ? -1 : 1)).toFixed(0)}deg`,
        "--_delay": between(0, 0.14).toFixed(3),
        "--_s": `${size.toFixed(1)}px`,
      } as CSSProperties,
    };
  });
}

export function Celebrate({
  burstKey,
  origin = "center",
  count = 28,
  message,
  className,
  children,
  ...rest
}: CelebrateProps) {
  const [bursts, setBursts] = useState<Burst[]>([]);
  const [status, setStatus] = useState("");
  const lastKey = useRef(burstKey);
  const serial = useRef(0);
  const ended = useRef(new Map<number, number>());
  const statusRef = useRef<HTMLSpanElement>(null);

  useEffect(() => {
    if (Object.is(lastKey.current, burstKey)) return;
    lastKey.current = burstKey;
    serial.current += 1;
    const id = serial.current;

    /* Alternating a trailing space makes the same message announce again. */
    if (message) setStatus(id % 2 ? message : `${message} `);

    const probe = statusRef.current;
    const moving = probe ? parseFloat(getComputedStyle(probe).transitionDuration) > 0 : false;
    if (!moving || count <= 0) return;

    const point = origin === "top" ? { x: 50, y: 0 } : origin === "center" || origin === "centre" ? { x: 50, y: 50 } : origin;
    const pieces = makePieces(count, hash(String(burstKey)) ^ Math.imul(id, 2654435761));
    setBursts((list) => [...list, { id, x: point.x, y: point.y, pieces }]);
    // The burst is keyed on burstKey alone; other props are read as they are at that moment.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [burstKey]);

  function handleAnimationEnd(event: AnimationEvent<HTMLSpanElement>, burst: Burst) {
    if (!(event.target instanceof Element) || !event.target.classList.contains("eq-celebrate-piece")) return;
    const done = (ended.current.get(burst.id) ?? 0) + 1;
    if (done < burst.pieces.length) {
      ended.current.set(burst.id, done);
      return;
    }
    ended.current.delete(burst.id);
    setBursts((list) => list.filter((b) => b.id !== burst.id));
  }

  return (
    <div {...rest} className={["eq-celebrate", className].filter(Boolean).join(" ")}>
      {children}
      <span ref={statusRef} className="eq-celebrate-status" role="status">
        {status}
      </span>
      {bursts.map((burst) => (
        <span
          key={burst.id}
          className="eq-celebrate-burst"
          aria-hidden="true"
          style={{ left: `${burst.x}%`, top: `${burst.y}%` }}
          onAnimationEnd={(event) => handleAnimationEnd(event, burst)}
        >
          {burst.pieces.map((piece, i) => (
            <span
              key={i}
              className="eq-celebrate-piece"
              data-tone={piece.tone}
              data-shape={piece.dot ? "dot" : "bar"}
              style={piece.style}
            />
          ))}
        </span>
      ))}
    </div>
  );
}
components/equip/celebrate.css87 lines
@layer equip {
  .eq-celebrate {
    position: relative;
    display: inline-block;
  }

  .eq-celebrate-status {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    border: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    /* celebrate.tsx reads this to learn whether motion is on; nothing on it ever animates. */
    transition-duration: var(--eq-dur-move);
  }

  .eq-celebrate-burst {
    position: absolute;
    width: 0;
    height: 0;
    pointer-events: none;
  }

  .eq-celebrate-piece {
    position: absolute;
    top: 0;
    left: 0;
    width: var(--_s);
    height: calc(var(--_s) * 0.45);
    margin: calc(var(--_s) * -0.225) 0 0 calc(var(--_s) * -0.5);
    border-radius: 1px;
    background: var(--eq-accent);
    opacity: 0;
    animation: eq-celebrate-fly calc(var(--eq-dur-move) * 3) var(--eq-ease) calc(var(--eq-dur-move) * var(--_delay)) both;
  }

  .eq-celebrate-piece[data-shape="dot"] {
    height: var(--_s);
    margin-top: calc(var(--_s) * -0.5);
    border-radius: var(--eq-radius-full);
  }

  .eq-celebrate-piece[data-tone="1"] {
    background: var(--eq-ink);
  }

  .eq-celebrate-piece[data-tone="2"] {
    background: var(--eq-focus);
  }

  .eq-celebrate-piece[data-tone="3"] {
    background: var(--eq-ok);
  }

  .eq-celebrate-piece[data-tone="4"] {
    background: var(--eq-warn);
  }

  /* Out on the house curve, then down under gravity, fading as it lands. */
  @keyframes eq-celebrate-fly {
    0% {
      translate: 0 0;
      rotate: 0deg;
      opacity: 1;
    }

    35% {
      translate: var(--_x) var(--_y);
      rotate: calc(var(--_spin) * 0.4);
      animation-timing-function: ease-in;
    }

    70% {
      opacity: 1;
    }

    100% {
      translate: calc(var(--_x) * 1.3) calc(var(--_y) + var(--_fall));
      rotate: var(--_spin);
      opacity: 0;
    }
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index