{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"pulse-choir","type":"registry:ui","title":"Pulse Choir","description":"Thirty-two bars on one clock, each with a fixed peak, so the field reads as a single waveform rather than thirty-two loaders.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"components/ui/pulse-choir.tsx","target":"components/ui/pulse-choir.tsx","content":"'use client';\n\nimport type { CSSProperties } from 'react';\n\nimport './pulse-choir.css';\n\n/** 32 bars, each with a fixed peak so the field reads as one waveform. */\nconst BARS = 32;\n\n/**\n * `compact` is the 298x240 catalogue card: the same 32 bars on the same loop, handed\n * the whole frame instead of a 500px block in the middle of it. The field runs on its\n * own, so the card is alive on load with nothing to hover; and nothing in here is\n * focusable or takes a pointer event, so there is no tab stop to blank under the\n * frame's `aria-hidden`. Presentation only — see `pulse-choir.css`.\n */\nexport type PulseChoirProps = { compact?: boolean };\n\nexport function PulseChoir({ compact = false }: PulseChoirProps) {\n  return (\n    <div className=\"pulse-choir-stage\" data-compact={compact ? 'true' : undefined}>\n      <div className=\"pulse-choir\" role=\"img\" aria-label=\"A synchronized field of equalizer bars\">\n        <div>\n          {Array.from({ length: BARS }, (_, index) => (\n            <i\n              style={\n                {\n                  '--i': index,\n                  '--peak': (0.2 + Math.abs(Math.sin(index * 0.72)) * 0.8).toFixed(2),\n                } as CSSProperties\n              }\n              key={index}\n            />\n          ))}\n        </div>\n        <span>\n          <b>LIVE</b> / MOTION FREQUENCY\n        </span>\n      </div>\n    </div>\n  );\n}\n","type":"registry:ui"},{"path":"components/ui/pulse-choir.css","target":"components/ui/pulse-choir.css","content":"@keyframes choir {\n  0% {\n    transform: scaleY(0.08);\n  }\n  55% {\n    transform: scaleY(var(--peak));\n  }\n  100% {\n    transform: scaleY(calc(1.05 - var(--peak) * 0.5));\n  }\n}\n\n@keyframes live-blink {\n  50% {\n    opacity: 0.2;\n  }\n}\n\n/* The surface the choir is designed to sit on. Size it from the parent. */\n.pulse-choir-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(ellipse at 50% 80%, #20266d, #111 58%);\n  color: #f0ede6;\n}\n\n.pulse-choir {\n  width: min(500px, 86%);\n  display: grid;\n  gap: 30px;\n}\n\n.pulse-choir > div {\n  height: 190px;\n  display: flex;\n  align-items: center;\n  gap: 5px;\n  border-bottom: 1px solid rgba(255, 255, 255, 0.35);\n}\n\n.pulse-choir i {\n  flex: 1;\n  height: 100%;\n  min-width: 3px;\n  background: linear-gradient(to top, #3155e7, #9b6dff 55%, #d8ff43);\n  transform: scaleY(0.08);\n  transform-origin: bottom;\n  animation: choir 1.65s calc(var(--i) * -55ms) cubic-bezier(0.45, 0, 0.55, 1) infinite\n    alternate;\n}\n\n.pulse-choir > span {\n  display: flex;\n  justify-content: space-between;\n  font: 8px ui-monospace, SFMono-Regular, Menlo, monospace;\n  letter-spacing: 0.1em;\n}\n\n.pulse-choir span > b {\n  color: #d8ff43;\n  font-weight: 500;\n}\n\n.pulse-choir span > b::before {\n  content: '';\n  display: inline-block;\n  width: 6px;\n  height: 6px;\n  margin-right: 6px;\n  border-radius: 50%;\n  background: #d8ff43;\n  box-shadow: 0 0 8px #d8ff43;\n  animation: live-blink 1s steps(1) infinite;\n}\n\n.pulse-choir > div {\n  position: relative;\n  padding: 0 10px;\n  background: linear-gradient(90deg, transparent, rgba(49, 85, 231, 0.12), transparent);\n  box-shadow: 0 28px 44px rgba(49, 85, 231, 0.16);\n}\n\n.pulse-choir > div::after {\n  content: '';\n  position: absolute;\n  left: 10px;\n  right: 10px;\n  bottom: -16px;\n  height: 13px;\n  background: inherit;\n  filter: blur(4px);\n  transform: scaleY(-0.45);\n  opacity: 0.45;\n}\n\n.pulse-choir i {\n  border-radius: 8px 8px 0 0;\n  box-shadow: 0 0 12px rgba(155, 109, 255, 0.28);\n}\n\n/*\n * The card variant: the 298x240 catalogue frame, at that real size and never scaled.\n *\n * There is no prose in this animation to drop — no headline, no paragraph, no `vw`\n * anywhere — so the thing that fails in a card is the width. `min(500px, 86%)` leaves\n * 236px inside the row's padding, and 32 bars floored at `min-width: 3px` plus 31\n * five-pixel gaps need 251px: every bar is pinned at that floor, a 3px hairline, and the\n * line still overruns by 15px. Nothing is cropped — the spill stops 16px short of the\n * stage's own edge, so `overflow: hidden` never reaches it — the last two bars simply\n * stand outside the underline and the reflection, which both end where the row does.\n * Closing the gap to 3px is what makes the field fit — 93px of gaps in a 262px row leaves\n * 169px, so a bar is 5.28px and the floor never binds.\n *\n * Vertically the frame goes to the mechanism. 190 + 30 + 11 did fit 240px, with about 4px\n * of air top and bottom, but 41 of those 240 were a gap and a strip of 8px type. Here the\n * label keeps one 13px line at 10px and the bars take the 193px left over — taller than\n * the 190px they get on the full stage.\n */\n.pulse-choir-stage[data-compact='true'] {\n  min-height: 0;\n  height: 100%;\n  /*\n   * The only padding in the file, and it replaces an inset that used to be implied by\n   * the 86% width. 8px at the sides plus the row's own 10px keeps the first and last bar\n   * clear of the card frame's 14px corner radius (`rounded-xl` on this project's 0.625rem\n   * `--radius`); 12px above is what the tallest peak in the set — a bar whose keyframe\n   * reaches `scaleY(1)` — has instead of the top edge.\n   */\n  padding: 12px 8px 10px;\n}\n\n/*\n * No `touch-action` line here, and none needed: this stage has no pointer handlers and\n * never captures a gesture, so a touch on the card scrolls the page as it should and\n * declaring `pan-y` would only cost the card its pinch-zoom. No `border-radius` line\n * either — the stage never set one, and the card frame rounds and clips already.\n */\n\n/*\n * Fills the frame rather than sitting in the middle of it, and the rows are stated so\n * the bars get everything the label does not. `1fr` rather than a measured height: the\n * line box under a 10px monospace face is the webfont's business, not a number to\n * hard-code and then be wrong about.\n */\n.pulse-choir-stage[data-compact='true'] .pulse-choir {\n  justify-self: stretch;\n  width: auto;\n  height: 100%;\n  grid-template-rows: minmax(0, 1fr) auto;\n  gap: 12px;\n}\n\n/*\n * The row takes the whole `1fr`, and it has to say so in a percentage rather than in\n * `auto`: 190px is a definite height and a definite height does not stretch, but the bars\n * inside are `height: 100%` and want a definite one to resolve against, so leaving this\n * to stretch alignment would put the field's height on the least portable step of the\n * chain. The drop shadow comes up with the row — 28px down over 44px of blur throws most\n * of the wash past the bottom edge, where 10px of padding is all there is to catch it.\n */\n.pulse-choir-stage[data-compact='true'] .pulse-choir > div {\n  height: 100%;\n  gap: 3px;\n  box-shadow: 0 14px 26px rgba(49, 85, 231, 0.16);\n}\n\n/*\n * The reflection, pulled inside the smaller gap. At -16px over 13px it hangs from 3px to\n * 16px below the baseline, which is four pixels into a label that now starts at twelve;\n * -7px over 8px keeps the flip touching the line it belongs to and stops short of the\n * type, with only the blur tail reaching the leading above it.\n */\n.pulse-choir-stage[data-compact='true'] .pulse-choir > div::after {\n  bottom: -7px;\n  height: 8px;\n}\n\n/*\n * Stated rather than left to the radius clamp: at 5.28px wide the browser scales 8px down\n * to 2.6px on its own, which is near enough to this and arrived at by arithmetic nobody\n * can read off the file.\n */\n.pulse-choir-stage[data-compact='true'] .pulse-choir i {\n  border-radius: 3px 3px 0 0;\n}\n\n/*\n * The one line kept, at 10px instead of 8px. It is a label and not prose — the card's own\n * title and description say what the item is — and it earns its 13px by naming the field\n * and carrying the blinking dot, the only channel in here running at a different rate to\n * the bars. The text is about 166px of the 282px strip at this size, so `space-between`\n * still reads as two ends of one line rather than a broken sentence.\n */\n.pulse-choir-stage[data-compact='true'] .pulse-choir > span {\n  font-size: 10px;\n}\n\n/*\n * `choir` starts at scaleY(0.08) — a flat line — so the still is each bar at its own peak\n * instead. `--peak` already differs per bar, so the meter freezes as a varied profile\n * rather than a row of equal blocks, and the blink resolves to its base steady state.\n */\n@media (prefers-reduced-motion: reduce) {\n  .pulse-choir i {\n    animation: none;\n    transform: scaleY(var(--peak));\n  }\n\n  .pulse-choir span > b::before {\n    animation: none;\n  }\n}\n","type":"registry:file"}],"meta":{"kind":"animations","categories":["loaders","infinite","stagger"],"docs":"https://ui.artbloom.tech/artbloom/animations/pulse-choir"}}