{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"curtain-sequence","type":"registry:ui","title":"Curtain Sequence","description":"Six coloured panels sweep in, hinge from the far edge, and leave — the title resolves mid-pass. Press to run.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"components/ui/curtain-sequence.tsx","target":"components/ui/curtain-sequence.tsx","content":"'use client';\n\nimport { useEffect, useState } from 'react';\nimport type { CSSProperties } from 'react';\n\nimport './curtain-sequence.css';\n\nexport type CurtainSequenceProps = {\n  /** Shown on the plate before the run. */\n  label?: string;\n  /** Replaces it while the panels are travelling. */\n  activeLabel?: string;\n  /**\n   * The 298x240 catalogue card: the editorial caption and the 28px viewfinder margin\n   * go, and the plate presses itself. The composition is all in `curtain-sequence.css`;\n   * the self-running part is the effect below.\n   */\n  compact?: boolean;\n};\n\n/** Six panels, not a prop: the stylesheet times each one by `nth-child`. */\nconst PANELS = 6;\n\n/** One run, end to end: 2.45s of travel plus the last panel's 375ms of stagger. */\nconst RUN_MS = 2850;\n\n/**\n * The still frame a card holds between runs. Long enough for the title to land and be\n * read once the panels have left, short enough that the wipe — the reason the card is\n * worth looking at — is running about three quarters of the time.\n */\nconst CARD_REST_MS = 1000;\n\nexport function CurtainSequence({\n  label = 'CURTAIN',\n  activeLabel = 'SEQUENCE',\n  compact = false,\n}: CurtainSequenceProps = {}) {\n  const [running, setRunning] = useState(false);\n  const run = () => {\n    if (running) return;\n    setRunning(true);\n    window.setTimeout(() => setRunning(false), RUN_MS);\n  };\n\n  /*\n   * In a card the plate presses itself. There is no solver here to be alive on its own:\n   * the six panels sit turned away from the viewer — `rotateY(-92deg)`, two degrees past\n   * the point `backface-visibility: hidden` stops drawing them — until something sets\n   * `running`, so a card of this stage is a still, which is the presentation the card\n   * variants exist to replace. Flipping the same state a click flips keeps the label swap,\n   * the z-index lift and the authored 2.85s exactly as they are. A press during the rest\n   * beat leaves its own timer alongside this one: both write `false` at the same point of\n   * the same run, so the overlap is a duplicate write rather than a second run.\n   *\n   * Left alone under `prefers-reduced-motion`: a full-bleed wipe restarting every four\n   * seconds, unasked, in a grid of these is what that setting is for. The still frame is\n   * what remains there, and pressing it still runs it. Read per cycle rather than latched\n   * into state, so switching the setting on stops the card at the next flip and there is\n   * no listener to add for it.\n   */\n  useEffect(() => {\n    if (!compact) return;\n    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;\n    const timer = window.setTimeout(\n      () => setRunning(!running),\n      running ? RUN_MS : CARD_REST_MS,\n    );\n    return () => window.clearTimeout(timer);\n  }, [compact, running]);\n\n  return (\n    <div className=\"curtain-sequence-stage\" data-compact={compact ? 'true' : undefined}>\n      <button\n        type=\"button\"\n        className={`curtain-sequence ${running ? 'running' : ''}`}\n        onClick={run}\n        aria-label=\"Run the curtain transition\"\n        // The card frame is aria-hidden and the card's own title link is the way in, so\n        // the plate leaves the tab order there rather than sitting as a focusable node\n        // inside a hidden box. It stays clickable in both.\n        tabIndex={compact ? -1 : undefined}\n        // The stylesheet divides the stage width by `--n` to size the plate. Both\n        // labels swap into the same slot, so the longer one sets the size.\n        style={\n          { '--n': Math.max(label.length, activeLabel.length) } as CSSProperties\n        }\n      >\n        <span>{running ? 'Sequence running' : 'Press to run the sequence'}</span>\n        <div aria-hidden=\"true\">\n          {Array.from({ length: PANELS }, (_, index) => (\n            <i style={{ '--panel': index } as CSSProperties} key={index}>\n              <small>{String(index + 1).padStart(2, '0')}</small>\n            </i>\n          ))}\n        </div>\n        <b>\n          <em>{running ? activeLabel : label}</em>\n          <small>{running ? 'FRAME LOCKED' : 'EDITORIAL TRANSITION'}</small>\n        </b>\n      </button>\n    </div>\n  );\n}\n","type":"registry:ui"},{"path":"components/ui/curtain-sequence.css","target":"components/ui/curtain-sequence.css","content":"@keyframes curtain-panel {\n  0% {\n    transform: translateX(-135%) rotateY(-92deg);\n    transform-origin: left center;\n  }\n  35%,\n  59% {\n    transform: translateX(0) rotateY(0);\n    transform-origin: left center;\n  }\n  60% {\n    transform-origin: right center;\n  }\n  100% {\n    transform: translateX(135%) rotateY(92deg);\n    transform-origin: right center;\n  }\n}\n\n@keyframes curtain-title {\n  0%,\n  24% {\n    opacity: 0;\n    transform: scale(0.72);\n    filter: blur(12px);\n    color: #f0ede6;\n  }\n  43%,\n  62% {\n    opacity: 1;\n    transform: scale(1);\n    filter: blur(0);\n    color: #111;\n  }\n  76%,\n  100% {\n    opacity: 0;\n    transform: scale(1.18);\n    filter: blur(8px);\n    color: #f0ede6;\n  }\n}\n\n@keyframes curtain-index {\n  0%,\n  28% {\n    opacity: 0.18;\n    transform: translateY(0);\n  }\n  45%,\n  66% {\n    opacity: 1;\n    transform: translateY(118px);\n    color: #111;\n  }\n  100% {\n    opacity: 0.18;\n    transform: translateY(0);\n  }\n}\n\n/*\n * The surface the sequence is designed to sit on. `perspective` lives here as\n * well as on the button so the panels keep their depth if the stage is reused.\n */\n.curtain-sequence-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: #0d0d0c;\n  perspective: 1100px;\n}\n\n.curtain-sequence {\n  position: relative;\n  width: 100%;\n  height: 100%;\n  overflow: hidden;\n  border: 0;\n  background: radial-gradient(circle at 50% 46%, #2a2a27 0, #111 58%, #080807 100%);\n  color: #f0ede6;\n  perspective: 1100px;\n}\n\n.curtain-sequence::before {\n  content: '';\n  position: absolute;\n  inset: 28px;\n  border: 1px solid rgba(255, 255, 255, 0.16);\n  background:\n    linear-gradient(\n      90deg,\n      transparent 49.9%,\n      rgba(255, 255, 255, 0.08) 50%,\n      transparent 50.1%\n    ),\n    linear-gradient(\n      0deg,\n      transparent 49.9%,\n      rgba(255, 255, 255, 0.08) 50%,\n      transparent 50.1%\n    );\n}\n\n.curtain-sequence::after {\n  content: '12';\n  position: absolute;\n  right: 17px;\n  top: 12px;\n  color: rgba(255, 255, 255, 0.18);\n  font-size: 54px;\n  font-weight: 750;\n  letter-spacing: -0.08em;\n}\n\n.curtain-sequence > span {\n  position: absolute;\n  z-index: 9;\n  left: 17px;\n  bottom: 15px;\n  font: 8px ui-monospace, SFMono-Regular, Menlo, monospace;\n  text-transform: uppercase;\n  letter-spacing: 0.12em;\n}\n\n.curtain-sequence > div {\n  position: absolute;\n  z-index: 4;\n  inset: 0;\n  display: flex;\n  perspective: 900px;\n  pointer-events: none;\n}\n\n.curtain-sequence > div > i {\n  --delay: calc(var(--panel) * 75ms);\n  position: relative;\n  width: 16.667%;\n  height: 100%;\n  overflow: hidden;\n  border-right: 1px solid rgba(17, 17, 15, 0.38);\n  background: #d8ff43;\n  transform: translateX(-130%) rotateY(-92deg);\n  transform-origin: left center;\n  box-shadow: 16px 0 34px rgba(0, 0, 0, 0.22);\n  backface-visibility: hidden;\n}\n\n.curtain-sequence > div > i:nth-child(2) {\n  background: #f0ede6;\n}\n\n.curtain-sequence > div > i:nth-child(3) {\n  background: #3155e7;\n}\n\n.curtain-sequence > div > i:nth-child(4) {\n  background: #ff5a40;\n}\n\n.curtain-sequence > div > i:nth-child(5) {\n  background: #9b6dff;\n}\n\n.curtain-sequence > div > i:nth-child(6) {\n  background: #111;\n  color: white;\n}\n\n.curtain-sequence i > small {\n  position: absolute;\n  left: 50%;\n  bottom: 21px;\n  font: 7px ui-monospace, SFMono-Regular, Menlo, monospace;\n  transform: translateX(-50%);\n  opacity: 0.65;\n}\n\n.curtain-sequence > b {\n  position: relative;\n  z-index: 2;\n  display: grid;\n  place-items: center;\n  gap: 12px;\n  font-weight: 700;\n}\n\n.curtain-sequence b > em {\n  /* Sized from the stage, not the viewport, and divided by the letter count so\n     any label fits. `--n` is the longer of the two labels; see the component. */\n  font-size: clamp(20px, calc(135cqw / var(--n, 7)), 118px);\n  font-style: normal;\n  line-height: 0.75;\n  letter-spacing: -0.08em;\n  text-shadow: 0 22px 38px rgba(0, 0, 0, 0.55);\n}\n\n.curtain-sequence b > small {\n  font: 8px ui-monospace, SFMono-Regular, Menlo, monospace;\n  letter-spacing: 0.18em;\n  color: #8f8f89;\n}\n\n.curtain-sequence.running > div > i {\n  animation: curtain-panel 2.45s var(--delay) cubic-bezier(0.76, 0, 0.24, 1) both;\n}\n\n.curtain-sequence.running > b {\n  z-index: 6;\n  animation: curtain-title 2.8s ease-in-out both;\n}\n\n.curtain-sequence.running::after {\n  animation: curtain-index 2.8s ease-in-out both;\n}\n\n/*\n * The card variant: the 298x240 catalogue frame, at that real size and never scaled.\n * Most of this stage already tracked its container — the wordmark is a `cqw` clamp and\n * the panels are sixths of the width, so both arrive in a card the right size — and what\n * did not survive 320px of height becoming 240 is the chrome around them: a 28px\n * viewfinder margin, a 54px frame counter, two lines of 8px mono where there is room for\n * one, and a drop shadow measured against 118px type. The mechanism is six panels\n * crossing the whole box, which is what it already was, so nothing below touches the\n * panels themselves.\n */\n.curtain-sequence-stage[data-compact='true'] {\n  /* 240px was the floor this stage was authored never to drop under; in a card it is the\n     whole frame, so the floor has to go and the height comes from the frame. Nothing to\n     neutralise for the corners: this stage never set a radius, and the card clips. */\n  min-height: 0;\n  height: 100%;\n}\n\n/*\n * No `touch-action` line here, and none wanted: the panel layer is `pointer-events: none`\n * and the only pointer target is the plate, which is a button and not a drag surface. A\n * vertical swipe that starts on this card stays the page's — the one thing that must not\n * break inside a scrolling grid of cards — and `pan-y` would cost it its pinch-zoom for\n * nothing.\n */\n\n/*\n * The viewfinder, pulled in from 28px to 10px and stopped 26px short of the bottom edge.\n * The pair of 28px insets is 17% of the stage's authored 320px of height and 23% of the\n * card's 240, and the box they left was too tight for a 50px wordmark: 'SEQUENCE' comes to\n * something near 230px at that size — arithmetic off the face's advances, not a measured\n * render — and `curtain-title` takes it to scale 1.18 mid-run, which is ~272px against the\n * 242px those insets leave. 10px gives it 278px and the word clears the side rules at its\n * widest. The deeper bottom inset is the type strip below: the instruction ends up under\n * the rule rather than across it, which is the relationship it has at full size.\n */\n.curtain-sequence-stage[data-compact='true'] .curtain-sequence::before {\n  inset: 10px 10px 26px;\n}\n\n/*\n * The frame counter, the one thing here that scaled badly: 54px marks the corner of a\n * section and takes 18% of the width of a 298px card, a numeral arguing with the title\n * rather than dating it. At 28px it is a corner mark again. Left on its authored 118px\n * drop, because the numeral now starts at y=6 and lands at y=124 of 240, so the whole\n * travel stays inside the viewfinder and the card needs no keyframe of its own.\n */\n.curtain-sequence-stage[data-compact='true'] .curtain-sequence::after {\n  top: 6px;\n  right: 12px;\n  font-size: 28px;\n}\n\n/*\n * The one line of type that stays, off the corner and onto the strip along the bottom\n * edge, and lifted from 8px to 10px: it is the only text left in the card, so it is the\n * one thing here that has to be legible rather than atmospheric. 25 uppercase mono\n * characters at 10px with the authored 0.12em of tracking come to ~180px of the 278px the\n * strip has, so the longer of the two strings still sets on one line.\n *\n * It earns the strip because nothing else here says the plate is a control — the card runs\n * the sequence by itself, but the press is the item, and this is the line that turns over\n * to 'Sequence running' while the panels are out. Under `prefers-reduced-motion` the loop\n * never starts and this is the only thing telling a visitor the card is not a screenshot.\n */\n.curtain-sequence-stage[data-compact='true'] .curtain-sequence > span {\n  inset: auto 0 0 0;\n  padding: 0 10px 8px;\n  font-size: 10px;\n  text-align: center;\n}\n\n/*\n * 'EDITORIAL TRANSITION' at 8px under a 50px wordmark is a second whisper arguing with the\n * one along the bottom edge, and the card's own title and description already say what this\n * is. It takes the plate's 12px gap with it, so the wordmark is the whole of `b`.\n */\n.curtain-sequence-stage[data-compact='true'] .curtain-sequence b > small {\n  display: none;\n}\n\n/*\n * The wordmark, centred in the viewfinder rather than in the frame. A button centres its\n * content over its full height, so with the type strip holding the bottom 26px the title\n * would sit 8px low inside the rules and the crosshair would cross its caps instead of its\n * middle. Same box as the `::before` above, stated rather than inherited, which also takes\n * the one piece of this composition that relied on button centring off it. The `z-index` is\n * untouched: the running rule still lifts it to 6 and the panels still pass under it.\n *\n * The transition is for the other end of the run. `curtain-title` is `both`-filled and\n * leaves the word at opacity 0 / scale 1.18 / blur 8px, and `.running` comes off 50ms\n * later, so the resting wordmark cuts straight back in. Once per press that reads as the\n * shutter closing; on a card that presses itself every 3.85s it is a blink. Removing a\n * filled animation is a computed-value change from its own last frame, so these three\n * properties settle instead of snapping — and a browser that resolves that as a cut lands\n * exactly on today's behaviour, which is why this is one declaration and not a second\n * keyframe. Safe at the start of a run too: whatever it travels through, it hands over at\n * the 0.72 / blur(12px) the keyframe holds until 24% anyway. Compact only — a press at\n * full size is still the cut it was authored as.\n */\n.curtain-sequence-stage[data-compact='true'] .curtain-sequence > b {\n  position: absolute;\n  inset: 10px 10px 26px;\n  transition:\n    opacity 240ms ease-out,\n    transform 240ms ease-out,\n    filter 240ms ease-out;\n}\n\n/*\n * The drop shadow under the wordmark, rescaled with the type it belongs to. `0 22px 38px`\n * is 0.19em of offset and 0.32em of blur against the 118px the title reaches in a section;\n * left alone under a 50px card wordmark the same pixels are 0.44em and 0.76em — a dark\n * cloud sitting clear of the letters rather than a shadow under them, and it is at its most\n * visible in the half second the title is #111 over the light panels. 9px and 16px are that\n * authored ratio at this size.\n */\n.curtain-sequence-stage[data-compact='true'] .curtain-sequence b > em {\n  text-shadow: 0 9px 16px rgba(0, 0, 0, 0.55);\n}\n\n/*\n * The panel numbers, lifted clear of the type strip. A panel is a sixth of the stage —\n * broad in a section, 50px in a card — so the numbers on panels 3 and 4 land in the middle\n * of the card, which is exactly where the centred instruction now is, and the instruction\n * wins on z-index. 42px puts them in the lower third of the panel with the strip below them\n * empty.\n */\n.curtain-sequence-stage[data-compact='true'] .curtain-sequence i > small {\n  bottom: 42px;\n}\n","type":"registry:file"}],"meta":{"kind":"animations","categories":["transitions","three-d","micro"],"docs":"https://ui.artbloom.tech/artbloom/animations/curtain-sequence"}}