backgroundBeamsWithCollision
A Backgrounds block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call backgroundBeamsWithCollision() with no arguments for a working demo, or edit the code below live.
Implementation notes
Continuous falling-beam + runtime collision-detection loop (measures the container's real getBoundingClientRect().height every frame, matching the spec's note that the boundary is derived at runtime) that hides a beam and spawns a short particle burst the instant it crosses the floor, then resets for its next delay/duration/repeatDelay cycle indefinitely. Light background with a black headline plus a purple-to-pink gradient second line (background-clip technique, ThemeColor roles instead of literal hex per doctor rules), matching the spec's confirmed-via-screenshot visual. Exact beam count/spawn-rate/particle-scatter constants are reasonable authored defaults, not measured from the original (spec gives no exact numeric defaults for these).
Status: ported · Reference: Aceternity UI original
// Aceternity UI "Background Beams with Collision" — clean-room
// reimplementation from the public behavior/visual spec only (no upstream
// source viewed or copied). A light hero backdrop where thin glowing
// vertical beams continuously fall from the top of the section and burst
// into a small particle explosion when they hit the bottom edge.
//
// Every beam's fall is driven imperatively (direct `element.style.transform`
// writes from one shared `requestAnimationFrame` loop, the same
// continuous-many-elements idiom `backgroundBeams.ts` already uses for its
// own gradient sweep) rather than through reactive `State`, since dozens of
// beams updating every frame would be wasteful to route through per-property
// listeners. Each beam runs its own `[delay] -> [fall for duration] ->
// [pause for repeatDelay] -> repeat` cycle; a beam's current Y position is
// compared every frame against the *measured* inner container height (via
// `getBoundingClientRect()`, per the spec's own note that the collision
// boundary is derived at runtime rather than hard-coded), and the first frame
// a beam's position crosses that boundary within a cycle hides the beam and
// spawns a short-lived particle burst at that (x, boundary) point — the same
// "timer pushes a transient entry into reactive `State`, a matching
// `setTimeout` removes it" shape `shootingStars.ts` uses for its own
// streaks, reused here for the small scatter of dots instead of a whole
// streak, each animated via this package's `motion()` patch.
//
// The upstream spec's gradient headline uses a literal purple-to-pink hex
// gradient — like this package's `auroraText.ts`, the gradient is expressed
// as `ThemeColor` roles resolved through `themeColor()` and painted with the
// `background-clip: text` + `color: "transparent"` technique (an
// established doctor-compliant idiom in this package: `"transparent"` is an
// explicitly exempted CSS keyword, not a raw color literal).
import type { DomphyElement, ElementNode, Listener, State, StyleObject } from "@domphy/core";
import { toState } from "@domphy/core";
import { heading, motion, paragraph } from "@domphy/ui";
import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme";
export interface BackgroundBeamsWithCollisionBeamSpec {
/** Horizontal start position, in percent of the container width. Random by default. */
initialXPercent?: number;
/** Tilt applied to the falling beam, in deg. Random small tilt by default. */
rotate?: number;
/** Fall duration for one cycle, in seconds. Defaults to `8` (randomized +/-30% by default). */
duration?: number;
/** Initial delay before the first fall, in seconds. Random by default. */
delay?: number;
/** Pause between the beam disappearing at the bottom and its next fall, in seconds. Random by default. */
repeatDelay?: number;
}
export interface BackgroundBeamsWithCollisionProps {
/** Content layered above the beams. Defaults to a demo headline with a gradient second line. */
children?: DomphyElement | DomphyElement[];
/** Number of default beams generated when `beams` is omitted. Defaults to `12`. */
beamCount?: number;
/** Explicit per-beam overrides, replacing the generated defaults. */
beams?: BackgroundBeamsWithCollisionBeamSpec[];
/** Theme color family for every beam. Defaults to `"secondary"` (violet). */
beamColor?: ThemeColor;
/** Theme color family for the collision particle burst. Defaults to `beamColor`. */
particleColor?: ThemeColor;
/** Fall distance beyond the container's own height, in px — generous so beams always overshoot the measured floor before the loop notices. Defaults to `1800`. */
translateYDistance?: number;
/** Passthrough style merged onto the outer container. */
style?: StyleObject;
}
interface BeamRuntime {
element: HTMLElement | null;
leftPercent: number;
rotateDeg: number;
durationMs: number;
delayMs: number;
repeatDelayMs: number;
hasCollidedThisCycle: boolean;
previousPhaseMs: number;
}
interface ParticleBurstEntry {
key: string;
leftPercent: number;
topPx: number;
scatterXPercent: number[];
scatterYPx: number[];
}
const BEAM_INITIAL_Y = -60;
const PARTICLES_PER_BURST = 6;
function randomBetween(min: number, max: number): number {
return min + Math.random() * (max - min);
}
function buildDefaultBeams(count: number): Required<BackgroundBeamsWithCollisionBeamSpec>[] {
return Array.from({ length: count }, () => ({
initialXPercent: Math.random() * 100,
rotate: randomBetween(-8, 8),
duration: randomBetween(5.5, 10.5),
delay: randomBetween(0, 5),
repeatDelay: randomBetween(1.5, 5),
}));
}
function defaultCollisionContent(): DomphyElement[] {
return [
{
h1: [
{ span: "Ship faster with", _key: "plain-line", style: { display: "block" } as StyleObject } as DomphyElement,
{
span: "beams that never stop falling",
ariaHidden: "true",
_key: "gradient-line",
style: {
display: "block",
backgroundImage: (listener: Listener) =>
`linear-gradient(90deg, ${themeColor(listener, "shift-9", "secondary")}, ${themeColor(listener, "shift-9", "highlight")})`,
backgroundClip: "text",
WebkitBackgroundClip: "text",
color: "transparent",
WebkitTextFillColor: "transparent",
} as StyleObject,
} as DomphyElement,
],
$: [heading()],
} as DomphyElement<"h1">,
{
p: "Every collision at the bottom edge bursts into a tiny shower of light.",
$: [paragraph()],
} as DomphyElement,
];
}
let backgroundBeamsWithCollisionInstanceCounter = 0;
/**
* A light hero backdrop of thin falling beams that burst into a small
* particle explosion when they hit the bottom edge, continuously and
* indefinitely. Call with no arguments for a working demo — 12 randomized
* beams behind a two-line demo headline.
*/
function backgroundBeamsWithCollision(props: BackgroundBeamsWithCollisionProps = {}): DomphyElement<"div"> {
const instanceId = ++backgroundBeamsWithCollisionInstanceCounter;
const beamCount = Math.max(1, Math.round(props.beamCount ?? 12));
const beamColor = props.beamColor ?? "secondary";
const particleColor = props.particleColor ?? beamColor;
const translateYDistance = Math.max(200, props.translateYDistance ?? 1800);
const beamSpecs = props.beams && props.beams.length > 0 ? props.beams : buildDefaultBeams(beamCount);
const runtimes: BeamRuntime[] = beamSpecs.map((spec) => ({
element: null,
leftPercent: spec.initialXPercent ?? Math.random() * 100,
rotateDeg: spec.rotate ?? randomBetween(-8, 8),
durationMs: (spec.duration ?? randomBetween(5.5, 10.5)) * 1000,
delayMs: (spec.delay ?? randomBetween(0, 5)) * 1000,
repeatDelayMs: (spec.repeatDelay ?? randomBetween(1.5, 5)) * 1000,
hasCollidedThisCycle: false,
previousPhaseMs: 0,
}));
const particleBursts: State<ParticleBurstEntry[]> = toState([], `background-beams-collision-particles-${instanceId}`);
function particleBurstElement(entry: ParticleBurstEntry): DomphyElement<"span"> {
return {
span: entry.scatterXPercent.map((scatterX, dotIndex) => ({
span: null,
_key: `dot-${dotIndex}`,
ariaHidden: "true",
_doctorDisable: ["missing-color", "tone-background-inherit"],
style: {
position: "absolute",
insetInlineStart: 0,
insetBlockEnd: 0,
width: themeSpacing(0.75),
height: themeSpacing(0.75),
borderRadius: "50%",
backgroundColor: (listener: Listener) => themeColor(listener, "shift-11", particleColor),
} as StyleObject,
$: [
motion({
initial: { x: 0, y: 0, opacity: 1 },
animate: { x: `${scatterX}px`, y: `${entry.scatterYPx[dotIndex]}px`, opacity: 0 },
transition: { duration: 520, easing: "ease-out" },
}),
],
})) as DomphyElement[],
_key: entry.key,
ariaHidden: "true",
style: {
position: "absolute",
insetInlineStart: `${entry.leftPercent}%`,
top: `${entry.topPx}px`,
width: 0,
height: 0,
} as StyleObject,
};
}
function beamElement(runtime: BeamRuntime, index: number): DomphyElement<"span"> {
return {
span: null,
_key: `beam-${index}`,
ariaHidden: "true",
// Decorative streak with no text of its own — exempt from the
// missing-color contract (mirrors meteors.ts's dot spans elsewhere).
_doctorDisable: ["missing-color", "tone-background-inherit"],
_onMount: (node: ElementNode) => {
runtime.element = node.domElement as HTMLElement;
},
_onRemove: () => {
runtime.element = null;
},
style: {
position: "absolute",
top: 0,
left: `${runtime.leftPercent}%`,
width: themeSpacing(0.375),
height: themeSpacing(16),
opacity: 0,
pointerEvents: "none",
backgroundImage: (listener: Listener) =>
`linear-gradient(to bottom, transparent, ${themeColor(listener, "shift-9", beamColor)}, transparent)`,
} as StyleObject,
} as DomphyElement<"span">;
}
const contentChildren = props.children ? (Array.isArray(props.children) ? props.children : [props.children]) : defaultCollisionContent();
return {
div: [
{
div: [...runtimes.map((runtime, index) => beamElement(runtime, index)), { div: (listener) => particleBursts.get(listener).map(particleBurstElement), style: { position: "absolute", inset: 0 } as StyleObject }],
style: { position: "absolute", inset: 0, overflow: "hidden" } as StyleObject,
_onMount: (node: ElementNode) => {
if (typeof window === "undefined") return;
const floorElement = node.domElement as HTMLElement;
let animationFrameId: number | null = null;
let intersectionObserver: IntersectionObserver | null = null;
let insertCount = 0;
// `requestAnimationFrame`'s timestamp is time-since-navigation-start,
// not time-since-this-loop-started — capture the first frame's value
// so every beam's `delayMs` is relative to when THIS loop began.
let loopStartTimeMs: number | null = null;
const cleanupTimeouts = new Set<ReturnType<typeof setTimeout>>();
function spawnParticleBurst(leftPercent: number, topPx: number): void {
insertCount += 1;
const entry: ParticleBurstEntry = {
key: `burst-${instanceId}-${insertCount}`,
leftPercent,
topPx,
scatterXPercent: Array.from({ length: PARTICLES_PER_BURST }, () => randomBetween(-24, 24)),
scatterYPx: Array.from({ length: PARTICLES_PER_BURST }, () => randomBetween(-18, -2)),
};
particleBursts.set([...particleBursts.get(), entry]);
const removeHandle = setTimeout(() => {
cleanupTimeouts.delete(removeHandle);
particleBursts.set(particleBursts.get().filter((item) => item.key !== entry.key));
}, 600);
cleanupTimeouts.add(removeHandle);
}
function tick(timeMs: number): void {
if (loopStartTimeMs === null) loopStartTimeMs = timeMs;
const elapsedSinceStart = timeMs - loopStartTimeMs;
const boundaryHeight = floorElement.getBoundingClientRect().height;
for (const runtime of runtimes) {
if (!runtime.element) continue;
const cycleLengthMs = runtime.durationMs + runtime.repeatDelayMs;
const rawElapsed = elapsedSinceStart - runtime.delayMs;
const phaseMs = ((rawElapsed % cycleLengthMs) + cycleLengthMs) % cycleLengthMs;
if (phaseMs < runtime.previousPhaseMs) runtime.hasCollidedThisCycle = false;
runtime.previousPhaseMs = phaseMs;
if (rawElapsed < 0) {
runtime.element.style.opacity = "0";
continue;
}
if (phaseMs >= runtime.durationMs) {
runtime.element.style.opacity = "0";
continue;
}
const progress = phaseMs / runtime.durationMs;
const currentY = BEAM_INITIAL_Y + progress * (translateYDistance - BEAM_INITIAL_Y);
if (!runtime.hasCollidedThisCycle && boundaryHeight > 0 && currentY >= boundaryHeight) {
runtime.hasCollidedThisCycle = true;
runtime.element.style.opacity = "0";
spawnParticleBurst(runtime.leftPercent, boundaryHeight);
continue;
}
runtime.element.style.opacity = "1";
runtime.element.style.transform = `translateY(${currentY.toFixed(1)}px) rotate(${runtime.rotateDeg.toFixed(1)}deg)`;
}
animationFrameId = window.requestAnimationFrame(tick);
}
function startLoop(): void {
if (animationFrameId !== null) return;
animationFrameId = window.requestAnimationFrame(tick);
}
function stopLoop(): void {
if (animationFrameId === null) return;
window.cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
if (typeof IntersectionObserver === "function") {
intersectionObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) startLoop();
else stopLoop();
}
});
intersectionObserver.observe(floorElement);
} else {
startLoop();
}
node.addHook("Remove", () => {
stopLoop();
intersectionObserver?.disconnect();
for (const handle of cleanupTimeouts) clearTimeout(handle);
cleanupTimeouts.clear();
});
},
} as DomphyElement,
{ div: contentChildren, style: { position: "relative", zIndex: 1 } } as DomphyElement,
],
dataTone: "shift-1",
style: {
position: "relative",
overflow: "hidden",
borderRadius: themeSpacing(4),
padding: themeSpacing(10),
minHeight: themeSpacing(96),
backgroundColor: (listener) => themeColor(listener, "inherit"),
color: (listener) => themeColor(listener, "shift-9"),
...(props.style ?? {}),
} as StyleObject,
};
}
export { backgroundBeamsWithCollision };