{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"lenticular-shift","type":"registry:ui","title":"Lenticular Shift","description":"Eighteen slats turn in place so two words trade positions, the way a lenticular print changes with the angle you hold it at.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"components/ui/lenticular-shift.tsx","target":"components/ui/lenticular-shift.tsx","content":"'use client';\n\nimport type { CSSProperties } from 'react';\n\nimport './lenticular-shift.css';\n\nexport type LenticularShiftProps = {\n  /** The two words the surface alternates between as it turns. */\n  faces?: readonly [string, string];\n  /** Corner marker. */\n  label?: string;\n  /**\n   * The 298x240 catalogue card: the same plate and the same keyframes, given the\n   * frame's full width and 70% of its height, with the corner marker dropped.\n   * Presentation only, and all of it in `lenticular-shift.css`.\n   *\n   * Nothing in here is focusable in either mode — the plate is one `role=\"img\"`\n   * and the surface takes no pointer events — so there is no tab stop to pull out\n   * from under the card frame's `aria-hidden`.\n   */\n  compact?: boolean;\n};\n\n/** 18 slats, not a prop: the stylesheet divides the lens width by that count. */\nconst SLATS = 18;\n\nexport function LenticularShift({\n  faces = ['LENS', 'SHIFT'],\n  label = 'VIEW / SHIFT',\n  compact = false,\n}: LenticularShiftProps = {}) {\n  return (\n    <div className=\"lenticular-shift-stage\" data-compact={compact ? 'true' : undefined}>\n      <div\n        className=\"lenticular-shift\"\n        role=\"img\"\n        aria-label={`A lenticular surface alternating between ${faces[0]} and ${faces[1]}`}\n      >\n        {Array.from({ length: SLATS }, (_, index) => (\n          <i style={{ '--i': index } as CSSProperties} key={index}>\n            <span>{faces[0]}</span>\n            <b>{faces[1]}</b>\n          </i>\n        ))}\n        <em>{label}</em>\n      </div>\n    </div>\n  );\n}\n","type":"registry:ui"},{"path":"components/ui/lenticular-shift.css","target":"components/ui/lenticular-shift.css","content":"@keyframes ridge-shift {\n  0%,\n  18%,\n  88%,\n  100% {\n    transform: rotateY(-28deg);\n  }\n  46%,\n  65% {\n    transform: rotateY(28deg);\n  }\n}\n\n@keyframes ridge-front {\n  0%,\n  42%,\n  70%,\n  100% {\n    opacity: 1;\n  }\n  43%,\n  69% {\n    opacity: 0;\n  }\n}\n\n@keyframes ridge-back {\n  0%,\n  42%,\n  70%,\n  100% {\n    opacity: 0;\n  }\n  43%,\n  69% {\n    opacity: 1;\n  }\n}\n\n/*\n * The surface the lens is designed to sit on. `perspective` lives here as well\n * as on the root so the slats keep their depth if the stage is reused.\n */\n.lenticular-shift-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: #d8ff43;\n  perspective: 900px;\n}\n\n.lenticular-shift {\n  /*\n   * 420px is the design width. Below a ~440px stage the lens narrows with the\n   * stage so its layout box still fits and the grid above can centre it. `--slat`\n   * is derived rather than written twice, so the faces and their per-slat offset\n   * cannot drift out of step with the width; 18 is the slat count.\n   */\n  --w: min(420px, 96cqw);\n  --slat: calc(var(--w) / 18);\n  position: relative;\n  width: var(--w);\n  height: calc(var(--w) * 0.381);\n  display: flex;\n  perspective: 900px;\n  filter: drop-shadow(0 25px 24px rgba(60, 72, 12, 0.28));\n}\n\n.lenticular-shift > i {\n  --delay: calc(var(--i) * 22ms);\n  position: relative;\n  width: var(--slat);\n  height: calc(var(--w) * 0.381);\n  overflow: hidden;\n  border-right: 1px solid rgba(17, 17, 15, 0.24);\n  background: #111;\n  transform-style: preserve-3d;\n  animation: ridge-shift 5.6s var(--delay) cubic-bezier(0.76, 0, 0.24, 1) infinite;\n}\n\n.lenticular-shift i > span,\n.lenticular-shift i > b {\n  position: absolute;\n  left: calc(var(--i) * -1 * var(--slat));\n  top: 0;\n  width: var(--w);\n  height: calc(var(--w) * 0.381);\n  display: grid;\n  place-items: center;\n  font-size: calc(var(--w) * 0.1952);\n  font-style: normal;\n  font-weight: 800;\n  letter-spacing: -0.08em;\n  line-height: 1;\n}\n\n.lenticular-shift i > span {\n  color: #f0ede6;\n  animation: ridge-front 5.6s var(--delay) steps(1) infinite;\n}\n\n.lenticular-shift i > b {\n  color: #111;\n  background: #ff5a40;\n  opacity: 0;\n  animation: ridge-back 5.6s var(--delay) steps(1) infinite;\n}\n\n.lenticular-shift > em {\n  position: absolute;\n  left: 0;\n  bottom: -27px;\n  font: 8px ui-monospace, SFMono-Regular, Menlo, monospace;\n  font-style: normal;\n  letter-spacing: 0.13em;\n}\n\n/*\n * The card variant: the 298x240 catalogue frame, at that real size and never scaled.\n *\n * Nothing here is the full stage made smaller. The plate's height follows its width\n * (`--w * 0.381`), so the one rule that governs the composition changes meaning with the\n * box: at 420 wide it draws a 160-tall plate, half the height of the 320px item-page\n * stage, and in a 298px-wide card the same rule hands it 286x109 — 45% of the height, a\n * letterbox strip afloat in lime with an 8px marker under it. So the plate takes the\n * frame's full width and 70% of its height, and the lime stays as the band above and\n * below: the ground, and what the drop shadow lands on.\n */\n.lenticular-shift-stage[data-compact='true'] {\n  /*\n   * The frame is the only sizing authority — 240px of parent height here, 200 on the\n   * bookmark rows, 260 on the landing lists — not a 240px floor under a page section.\n   * No `border-radius: 0` line: this stage never set one, and the card frame clips.\n   */\n  min-height: 0;\n  height: 100%;\n}\n\n/* No `touch-action` line: this stage is keyframes end to end and takes no pointer events,\n   so it never claims the vertical gesture a card in a scrolling grid has to hand back. */\n\n/*\n * Full width rather than `min(420px, 96cqw)`: the 4% held back is a 6px sliver of lime down\n * each side against a 36px band top and bottom, which reads as a plate that missed its box\n * rather than as a margin. `cqw` resolves against this stage's own `container-type:\n * inline-size`, so it is the card's 298px and not the viewport's; and since `--slat` and the\n * per-slat face offsets both derive from `--w`, 18 slats and 18 word slices follow this line.\n */\n.lenticular-shift-stage[data-compact='true'] .lenticular-shift {\n  --w: 100cqw;\n  /*\n   * 70% is 168px of 240, where the aspect ratio above would have given the plate 109. A\n   * percentage and not `168px` because the same card is rendered 260 tall in the landing\n   * lists and taller again on an item page; the chain is definite either way, since the\n   * preview frame carries a pixel height and this stage is `height: 100%` of it.\n   */\n  height: 70%;\n}\n\n/* The slat and both of its faces fill the plate, so the three `--w * 0.381` heights that\n   together made the letterbox all follow the line above and cannot drift apart. */\n.lenticular-shift-stage[data-compact='true'] .lenticular-shift > i,\n.lenticular-shift-stage[data-compact='true'] .lenticular-shift i > span,\n.lenticular-shift-stage[data-compact='true'] .lenticular-shift i > b {\n  height: 100%;\n}\n\n/*\n * The word is the whole readout of the flip — the ridges turn, but what carries across a\n * grid is LENS becoming SHIFT — so it grows with the plate: 0.27 of the width against the\n * 0.1952 the plate was drawn at, which puts the type at 48% of the plate height where the\n * item page has it at 51%. `SHIFT` is the face that sets the ceiling: five caps of Inter 800\n * at 80px under the -0.08em tracking come to roughly 210px of the plate's 298 by advance\n * width — computed, not measured — and each `i` clips, so a face that outgrew the plate\n * would lose its outer letters rather than wrap. The ratio holds at any card width, since\n * type and plate both track `--w`.\n */\n.lenticular-shift-stage[data-compact='true'] .lenticular-shift i > span,\n.lenticular-shift-stage[data-compact='true'] .lenticular-shift i > b {\n  font-size: calc(var(--w) * 0.27);\n}\n\n/*\n * The marker goes: 8px of mono in the bottom band is a corner detail on a 420px plate and\n * grey lint on a 298px tile. Nothing is lost with it — the surface turns on its own from\n * the moment it mounts, so there is no gesture for a caption to explain, and the card's own\n * title link already names the item.\n */\n.lenticular-shift-stage[data-compact='true'] .lenticular-shift > em {\n  display: none;\n}\n\n/*\n * A lenticular print only ever shows one face at a time, so the still is one face, held:\n * the ridges parked at the loop's own rest angle and the front layer opaque with the back\n * hidden. Leaving both layers to their base opacity would cross-fade them into a double\n * exposure, which is the one thing the effect is not.\n */\n@media (prefers-reduced-motion: reduce) {\n  .lenticular-shift > i {\n    animation: none;\n    transform: rotateY(-28deg);\n  }\n\n  .lenticular-shift i > span {\n    animation: none;\n    opacity: 1;\n  }\n\n  .lenticular-shift i > b {\n    animation: none;\n    opacity: 0;\n  }\n}\n","type":"registry:file"}],"meta":{"kind":"animations","categories":["three-d","text","transitions"],"docs":"https://ui.artbloom.tech/artbloom/animations/lenticular-shift"}}