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