New
Relaxation TypingLoaders
Four bubbles rise, pop, and spray eight drops each on a shared clock. A liquid loader with no JS timer.
'use client';
import type { CSSProperties } from 'react';
import './bubble-burst.css';
/** Four bubbles, each spraying eight drops on the same clock. */
const BUBBLES = 4;
const DROPS = 8;
/**
* `compact` is the 298x240 catalogue card: the same four bubbles on the same clock,
* handed the whole frame at the size they were drawn at instead of the 0.75 miniature
* of the 320px stage the card fell back to before. Nothing is dropped — this stage
* carries no copy — and nothing in it is focusable in either mode, so the card frame's
* `aria-hidden` sits over no tab stop. Presentation only: see `bubble-burst.css`.
*/
export type BubbleBurstProps = { compact?: boolean };
export function BubbleBurst({ compact = false }: BubbleBurstProps) {
return (
<div className="bubble-burst-stage" data-compact={compact ? 'true' : undefined}>
<div className="bubble-burst" role="img" aria-label="Bubbles rising and bursting into drops">
{Array.from({ length: BUBBLES }, (_, bubble) => (
<span className="bubble" style={{ '--b': bubble } as CSSProperties} key={bubble}>
{Array.from({ length: DROPS }, (_, drop) => (
<i
className="bubble-drop"
style={{ '--d': drop } as CSSProperties}
key={drop}
/>
))}
</span>
))}
</div>
</div>
);
}@keyframes bubble-rise {
0% {
transform: translateY(90px) scale(0.15);
opacity: 0;
}
24% {
opacity: 1;
transform: translateY(35px) scale(1);
}
58% {
opacity: 1;
transform: translateY(-48px) scale(1);
}
68%,
100% {
opacity: 0;
transform: translateY(-52px) scale(0.3);
}
}
@keyframes drop-flight {
0%,
55% {
opacity: 0;
transform: rotate(var(--angle)) translateY(-18px) scaleY(0);
}
60% {
opacity: 1;
transform: rotate(var(--angle)) translateY(-38px) scaleY(1);
}
80%,
100% {
opacity: 0;
transform: rotate(var(--angle)) translateY(-64px) scaleY(0.3);
}
}
@keyframes bubble-ring {
0% {
scale: 0.25;
opacity: 0;
}
45% {
opacity: 0.75;
}
100% {
scale: 1.25;
opacity: 0;
}
}
/*
* The surface the loader is designed to sit on. Also the positioning context:
* `.bubble-burst::before` is the pool the drops fall into and is absolute.
*/
.bubble-burst-stage {
position: relative;
display: grid;
place-items: center;
width: 100%;
height: 100%;
min-height: 240px;
container-type: inline-size;
overflow: hidden;
background: radial-gradient(circle at 50% 60%, #557dff 0, #273ca4 42%, #0b1034 100%);
color: white;
}
.bubble-burst {
display: flex;
gap: 34px;
filter: drop-shadow(12px 14px 6px rgba(0, 0, 0, 0.28));
}
.bubble-burst .bubble {
--delay: calc(var(--b) * 0.17s);
position: relative;
width: 54px;
height: 54px;
}
.bubble-burst .bubble::before {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background: radial-gradient(
circle at 32% 24%,
#fff 0 5%,
#8bc8ff 9%,
#5479ff 44%,
#2734a0 100%
);
box-shadow: inset 0 0 12px rgba(255, 255, 255, 0.55);
animation: bubble-rise 1.9s var(--delay) cubic-bezier(0.22, 0.75, 0.3, 1) infinite;
}
.bubble-burst .bubble-drop {
--angle: calc(var(--d) * 45deg);
position: absolute;
left: 26px;
top: 26px;
width: 4px;
height: 12px;
border-radius: 5px;
background: #a8dfff;
transform-origin: 2px 2px;
transform: rotate(var(--angle)) translateY(-38px) scaleY(0);
animation: drop-flight 1.9s var(--delay) ease-out infinite;
}
.bubble-burst::before {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 270px;
height: 270px;
border: 1px solid rgba(168, 223, 255, 0.2);
border-radius: 50%;
transform: translate(-50%, -50%);
box-shadow: 0 0 80px rgba(118, 172, 255, 0.26);
animation: bubble-ring 1.9s ease-out infinite;
}
.bubble-burst .bubble::before {
filter: saturate(1.2);
box-shadow:
inset 7px 8px 13px rgba(255, 255, 255, 0.5),
inset -10px -12px 20px rgba(18, 28, 107, 0.5),
0 14px 25px rgba(0, 0, 0, 0.25);
}
.bubble-burst .bubble:nth-child(even) {
transform: translateY(18px) scale(0.8);
}
.bubble-burst .bubble-drop {
box-shadow: 0 0 8px #a8dfff;
}
@container (max-width: 520px) {
.bubble-burst {
gap: 14px;
scale: 0.82;
}
}
/*
* The card variant: the 298x240 catalogue frame, at that real size and never scaled.
* Nothing is hidden here because there is nothing to hide — the stage is the loader and no
* copy, and nothing in it takes a pointer event, so this block carries no `display: none`
* and no `touch-action: pan-y`: a touch on the card still scrolls the page. What the card
* got instead was two shrinks at once — the preview fallback rendering the stage at 0.75,
* and the phone-viewport rule above, which a 298px container matches as well — which left
* 33px spheres in the middle of the frame. So the whole job here is size: the spheres go
* back to the 54px they were drawn at, and the width goes to the spray.
*/
.bubble-burst-stage[data-compact='true'] {
/* The frame owns the box and rounds and clips it, so there is no radius to neutralise
here — only the 240px floor, which would overflow a shorter frame instead of fill it. */
min-height: 0;
height: 100%;
}
/*
* The gap is set by the spray rather than by taste. Each of the eight drops is 4x12 with
* its transform origin 2px in, so at the lit keyframe its tip stands 40px off its sphere's
* centre and its 8px glow reaches 48. Four 54px spheres at a 12px gap make a 252px row,
* which leaves the outer centres 50px inside each edge: the lit tip clears by 10px and its
* glow by 2. The authored 34px gap wants a 360px frame for the same clearance and overflows
* this one by 20px on the row alone. Nothing here is a `vw`: the card is 298px wide and the
* viewport is not.
*
* The tail is the compromise. Past the lit keyframe a drop flies on to 64px as it fades, so
* the two outermost sprays do cross the side edges — at about 0.6 opacity, gone 15px later.
* Holding the whole flight inside needs a 4px gap, which crams the four spheres into a
* chain: spray that leaves the frame reads better than bubbles that touch.
*/
.bubble-burst-stage[data-compact='true'] .bubble-burst {
gap: 12px;
scale: 1;
}
/*
* The 270px pulse ring is the one fixed size in the file bigger than the frame, and it does
* not clip where it looks as though it would. The `scale` property is applied after
* `transform`, so the ring's own `translate(-50%, -50%)` is scaled along with it: the box
* grows up and to the left from a bottom-right corner pinned 135px past the centre, which
* in a 240px frame sits at y=255 for the whole cycle. So what the bottom edge cuts flat is
* the hairline itself, at its 45% opacity peak, and the brightest part of the 80px glow with
* it — not the invisible tail. 210px pins that corner at y=225 and keeps the proportion the
* full stage was drawn at: 270 against its 318px row of spheres, 210 against this 252px one.
* The thread that crosses the top edge later is down to ~0.07 alpha. Nothing is traded away
* behind the spheres either way — `::before` paints before its siblings, so the ring is
* already under them.
*/
.bubble-burst-stage[data-compact='true'] .bubble-burst::before {
width: 210px;
height: 210px;
}
/*
* `bubble-rise` starts below the frame at opacity 0, so stopping the loop where it begins
* would empty the card. Instead each bubble is parked at a different altitude inside the
* band where the loop has it at full size and opacity — `--b` is already the per-bubble
* index, so four bubbles spread across the rise and read as a still of the same scene.
*
* The other two need nothing but the loop switched off: the drops' base transform is
* `scaleY(0)`, which is already invisible, and the ring's base is its full-size resting
* state, which is the frame worth keeping.
*/
@media (prefers-reduced-motion: reduce) {
.bubble-burst .bubble::before {
animation: none;
transform: translateY(calc(35px - var(--b) * 26px));
opacity: 1;
}
.bubble-burst .bubble-drop,
.bubble-burst::before {
animation: none;
}
}