{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"contrary-shadow-section","type":"registry:component","title":"Contrary Shadow Section","description":"Mounted letters on a lit plaster wall under a swinging lamp: every word's shadow runs from the light, except one, whose shadow runs toward it.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/contrary-shadow-section.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport type { CSSProperties, ReactNode } from \"react\";\n\n/**\n * ContraryShadowSection — mounted letters on a lit wall; every shadow runs from the lamp but one.\n *\n * The statement is set as dark letters standing off a pale plaster wall,\n * lit by a small lamp on a cord. Every word casts a shadow computed each\n * frame from the lamp's position (a point light 700px off the wall): the\n * further a word is from the lamp, the longer and softer its shadow. One\n * word, chosen by `contrary`, casts its shadow toward the lamp instead. The\n * lamp swings slowly (11.3 s) under a pivot that follows a mouse pointer\n * with a 0.35 s lag; touch drags it. A pool of lamplight on the wall follows\n * it, and a placard reads where the lamp is and how many shadows run each\n * way. It has a Pause control; reduced motion holds the lamp still with\n * every shadow settled.\n *\n * Part of the Attention kit: a style of anomalies, familiar things made wrong\n * (fish in dry air, a stone that breaks upward, a shadow that runs toward its\n * lamp). One subject, one motivated light, one broken rule, and everything\n * else calm. Cut byte for byte from the kit's one site (AttentionSite, a\n * demo studio called Aerium). Paste it as its own file: it carries its own\n * copy of every helper it uses and has no dependencies.\n */\n\nconst HEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;\n\n/** The user's reduced-motion setting, live. Server and first render say \"no\". */\nfunction useAReducedMotion(): boolean {\n  const [reduced, setReduced] = useState(false);\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const on = () => setReduced(mq.matches);\n    on();\n    mq.addEventListener(\"change\", on);\n    return () => mq.removeEventListener(\"change\", on);\n  }, []);\n  return reduced;\n}\n\n/** The kit's type: Instrument Sans (through --font-instrument) over Geist. */\nconst A_SANS = 'var(--font-instrument, \"Instrument Sans\"), \"Geist\", system-ui, sans-serif';\n\n/** A static plaster grain (fixed texture, never animated). */\nconst A_GRAIN =\n  \"url(\\\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.1 0 0 0 0 0.1 0 0 0 0 0.1 0 0 0 0.55 0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E\\\")\";\n\nexport interface ContraryShadowSectionProps {\n  /** The statement, set as mounted letters. */\n  statement?: string;\n  /** The word whose shadow falls toward the lamp (matched without punctuation, any case). */\n  contrary?: string;\n  /** Small line above the statement. */\n  eyebrow?: string;\n  /** The paragraph under it. */\n  body?: string;\n  /** How far the letters stand off the wall, 4-24px: longer, softer shadows. */\n  standoff?: number;\n  /** The lamp swings slowly on its cord (11.3 s); off holds it still. */\n  swing?: boolean;\n  /** Show a Pause control for the swing. */\n  pauseControl?: boolean;\n  /** Wall colour. */\n  wall?: string;\n  /** Extra content under the paragraph. */\n  children?: ReactNode;\n  className?: string;\n  style?: CSSProperties;\n}\n\n/**\n * ContraryShadowSection — mounted letters on a lit wall; every word's shadow\n * runs from the lamp except one, which runs toward it.\n */\nexport function ContraryShadowSection({\n  statement = \"Every room has one thing wrong.\",\n  contrary = \"wrong\",\n  eyebrow = \"Fig. 02 / The studio\",\n  body = \"We take a rule you never think about (water holds fish, stone falls, shadows run from the light) and break it in a room that is otherwise perfectly ordinary. Everything else stays obedient, so the one wrong thing is all you see.\",\n  standoff = 14,\n  swing = true,\n  pauseControl = true,\n  wall = \"#d3d6d4\",\n  children,\n  className = \"\",\n  style,\n}: ContraryShadowSectionProps) {\n  const hostRef = useRef<HTMLElement>(null);\n  const lampRef = useRef<HTMLDivElement>(null);\n  const cordRef = useRef<HTMLDivElement>(null);\n  const readRef = useRef<HTMLSpanElement>(null);\n  const [paused, setPaused] = useState(false);\n  const reduced = useAReducedMotion();\n  const words = statement.split(/\\s+/).filter(Boolean);\n  const key = contrary.toLowerCase().replace(/[^\\p{L}\\p{N}]/gu, \"\");\n  const odd = words.findIndex((w) => w.toLowerCase().replace(/[^\\p{L}\\p{N}]/gu, \"\") === key);\n  const D = Math.min(24, Math.max(4, Number.isFinite(standoff) ? standoff : 14));\n  const opts = useRef({ D, swing, paused, reduced });\n  const kick = useRef<() => void>(() => {});\n  useEffect(() => {\n    opts.current = { D, swing, paused, reduced };\n    kick.current();\n  }, [D, swing, paused, reduced]);\n\n  useEffect(() => {\n    const host = hostRef.current;\n    if (!host) return;\n    let W = 1;\n    let H = 1;\n    let marks: { el: HTMLElement; x: number; y: number; odd: boolean }[] = [];\n    const measure = () => {\n      const r = host.getBoundingClientRect();\n      W = Math.max(1, r.width);\n      H = Math.max(1, r.height);\n      marks = [...host.querySelectorAll<HTMLElement>(\"[data-a-word]\")].map((el) => {\n        const b = el.getBoundingClientRect();\n        return { el, x: b.left - r.left + b.width / 2, y: b.top - r.top + b.height / 2, odd: el.dataset.aWord === \"odd\" };\n      });\n    };\n    // The lamp hangs from a pivot on the ceiling line; the pivot follows the pointer, the lamp swings under it.\n    let pivot = -1;\n    let pivotGoal = -1;\n    let pointer = false;\n    let raf = 0;\n    let last = 0;\n    let onScreen = true;\n    let lastSide = \"\";\n    const t0 = performance.now();\n    const frame = (now: number) => {\n      raf = 0;\n      const o = opts.current;\n      const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;\n      last = now;\n      const still = o.reduced || o.paused || !o.swing;\n      const rest = W * (W < 768 ? 0.3 : 0.24);\n      if (pivot < 0) pivot = pivotGoal = rest;\n      if (!pointer) pivotGoal = rest;\n      // Pointer chase, time constant 0.35 s (instant when motion is reduced).\n      pivot = o.reduced ? pivotGoal : pivot + (pivotGoal - pivot) * (1 - Math.exp(-dt / 0.35));\n      const t = (now - t0) / 1000;\n      const amp = pointer ? 0.05 : 0.2;\n      const theta = still ? 0.12 : amp * Math.sin((t / 11.3) * Math.PI * 2) + 0.06;\n      const cord = Math.max(90, H * 0.2);\n      const lx = pivot + Math.sin(theta) * cord;\n      const ly = Math.cos(theta) * cord;\n      // The lamp's height off the wall, in px: the shorter it is, the longer the shadows.\n      const Z = 700;\n      for (const m of marks) {\n        const dx = m.x - lx;\n        const dy = m.y - ly;\n        const k = o.D / (Z - o.D);\n        const sx = (m.odd ? -1 : 1) * dx * k;\n        const sy = (m.odd ? -1 : 1) * dy * k;\n        const len = Math.hypot(sx, sy);\n        const blur = 1.5 + 0.38 * len;\n        const a = Math.max(0.2, 0.5 - 0.006 * len);\n        m.el.style.textShadow =\n          \"0 -1px 0 rgba(255,255,255,0.42), \" +\n          (sx * 0.16).toFixed(1) + \"px \" + (sy * 0.16).toFixed(1) + \"px 1px rgba(12,15,18,0.34), \" +\n          sx.toFixed(1) + \"px \" + sy.toFixed(1) + \"px \" + blur.toFixed(1) + \"px rgba(12,15,18,\" + a.toFixed(3) + \")\";\n      }\n      host.style.setProperty(\"--lx\", ((lx / W) * 100).toFixed(2) + \"%\");\n      host.style.setProperty(\"--ly\", ((ly / H) * 100).toFixed(2) + \"%\");\n      if (lampRef.current) lampRef.current.style.transform = \"translate3d(\" + (lx - 13).toFixed(1) + \"px,\" + (ly - 4).toFixed(1) + \"px,0) rotate(\" + (-theta).toFixed(4) + \"rad)\";\n      if (cordRef.current) cordRef.current.style.transform = \"translate3d(\" + pivot.toFixed(1) + \"px,0,0) rotate(\" + (-theta).toFixed(4) + \"rad)\";\n      if (cordRef.current) cordRef.current.style.height = cord.toFixed(0) + \"px\";\n      const side = lx < W * 0.4 ? \"left of centre\" : lx > W * 0.6 ? \"right of centre\" : \"centre\";\n      if (side !== lastSide && readRef.current) {\n        lastSide = side;\n        readRef.current.textContent = \"Lamp: \" + side;\n      }\n      const moving = Math.abs(pivotGoal - pivot) > 0.3;\n      if (onScreen && !document.hidden && (!still || moving)) raf = requestAnimationFrame(frame);\n      else last = 0;\n    };\n    kick.current = () => {\n      if (!raf) raf = requestAnimationFrame(frame);\n    };\n    const toLocal = (e: PointerEvent) => {\n      const r = host.getBoundingClientRect();\n      pivotGoal = Math.min(W * 0.94, Math.max(W * 0.06, e.clientX - r.left));\n    };\n    const onMove = (e: PointerEvent) => {\n      if (e.pointerType === \"mouse\" || e.buttons) {\n        pointer = true;\n        toLocal(e);\n        kick.current();\n      }\n    };\n    const onDown = (e: PointerEvent) => {\n      pointer = true;\n      toLocal(e);\n      kick.current();\n    };\n    const onLeave = (e: PointerEvent) => {\n      if (e.pointerType === \"mouse\" || e.type === \"pointercancel\") {\n        pointer = false;\n        kick.current();\n      }\n    };\n    const ro = typeof ResizeObserver !== \"undefined\" ? new ResizeObserver(() => {\n      measure();\n      kick.current();\n    }) : null;\n    ro?.observe(host);\n    const io = typeof IntersectionObserver !== \"undefined\" ? new IntersectionObserver((es) => {\n      onScreen = es.some((e) => e.isIntersecting);\n      if (onScreen) {\n        measure();\n        kick.current();\n      }\n    }) : null;\n    io?.observe(host);\n    const onVis = () => kick.current();\n    document.addEventListener(\"visibilitychange\", onVis);\n    host.addEventListener(\"pointermove\", onMove);\n    host.addEventListener(\"pointerdown\", onDown);\n    host.addEventListener(\"pointerleave\", onLeave);\n    host.addEventListener(\"pointercancel\", onLeave);\n    // Fonts change the words' boxes.\n    document.fonts?.ready.then(() => {\n      measure();\n      kick.current();\n    });\n    measure();\n    kick.current();\n    return () => {\n      if (raf) cancelAnimationFrame(raf);\n      raf = 0;\n      ro?.disconnect();\n      io?.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n      host.removeEventListener(\"pointermove\", onMove);\n      host.removeEventListener(\"pointerdown\", onDown);\n      host.removeEventListener(\"pointerleave\", onLeave);\n      host.removeEventListener(\"pointercancel\", onLeave);\n      kick.current = () => {};\n    };\n  }, [statement, contrary]);\n\n  const ground = HEX.test(wall) ? wall : \"#d3d6d4\";\n  const sized = /(^|\\s)(min-)?h-/.test(className);\n  const away = words.length - (odd >= 0 ? 1 : 0);\n  return (\n    <section\n      ref={hostRef}\n      className={\"@container relative isolate flex flex-col overflow-hidden\" + (sized ? \"\" : \" min-h-[620px]\") + (className ? \" \" + className : \"\")}\n      style={{ backgroundColor: ground, fontFamily: A_SANS, touchAction: \"pan-y pinch-zoom\", ...style }}\n    >\n      <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\" style={{ backgroundImage: A_GRAIN, opacity: 0.11, mixBlendMode: \"multiply\" }} />\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0\"\n        style={{ background: \"radial-gradient(circle at var(--lx, 24%) var(--ly, 18%), rgba(255,251,242,0.62) 0, rgba(255,251,242,0.18) 26%, rgba(255,251,242,0) 44%, rgba(16,20,24,0.06) 60%, rgba(16,20,24,0.3) 100%)\" }}\n      />\n      {/* The lamp: a cord from the ceiling line and a small shade with its bulb. */}\n      <div ref={cordRef} aria-hidden=\"true\" className=\"pointer-events-none absolute top-0 left-0 w-px origin-top\" style={{ height: 120, background: \"rgba(16,20,24,0.55)\" }} />\n      <div ref={lampRef} aria-hidden=\"true\" className=\"pointer-events-none absolute top-0 left-0 h-[22px] w-[26px] origin-[13px_4px]\">\n        <span className=\"absolute inset-x-[3px] top-[2px] h-[12px] rounded-t-[13px] rounded-b-[2px]\" style={{ background: \"linear-gradient(180deg,#2a3036,#14181c)\" }} />\n        <span className=\"absolute top-[11px] left-1/2 h-[9px] w-[9px] -translate-x-1/2 rounded-full\" style={{ background: \"#fff8ea\", boxShadow: \"0 0 18px 8px rgba(255,246,226,0.7), 0 0 60px 24px rgba(255,246,226,0.28)\" }} />\n      </div>\n      <div className=\"relative z-[1] flex flex-1 flex-col justify-center gap-[max(20px,2.4cqw)] px-[max(20px,6cqw)] pt-[max(96px,10cqw)] pb-[max(56px,7cqw)]\">\n        <p className=\"m-0 flex flex-wrap gap-x-4 gap-y-1 font-mono text-[10px] leading-[1.7] tracking-[0.16em] text-[#101418]/70 uppercase\">\n          <span>{eyebrow}</span>\n          <span ref={readRef}>Lamp: left of centre</span>\n          <span>\n            Shadows: {away} cast away{odd >= 0 ? \" / 1 cast toward\" : \"\"}\n          </span>\n        </p>\n        <h2 className=\"m-0 max-w-[15ch] text-[max(44px,8.4cqw)] leading-[0.98] font-[500] tracking-[-0.045em] text-[#101418]\">\n          {words.map((w, i) => (\n            <span key={i}>\n              <span data-a-word={i === odd ? \"odd\" : \"\"} className=\"inline-block\">\n                {w}\n              </span>\n              {i < words.length - 1 ? \" \" : \"\"}\n            </span>\n          ))}\n        </h2>\n        <p className=\"m-0 max-w-[52ch] text-[max(15px,1.2cqw)] leading-[1.55] text-[#101418]/78\">{body}</p>\n        {children}\n      </div>\n      {pauseControl && swing && !reduced && (\n        <button\n          type=\"button\"\n          aria-pressed={paused}\n          onClick={() => setPaused((p) => !p)}\n          className=\"absolute right-3 bottom-3 z-[2] rounded-[2px] px-2.5 py-1.5 font-mono text-[10px] tracking-[0.14em] text-[#101418]/72 uppercase transition-colors hover:text-[#101418] focus-visible:outline-2! focus-visible:outline-offset-2! focus-visible:outline-[#101418]!\"\n          style={{ background: \"rgba(255,255,255,0.32)\", boxShadow: \"inset 0 0 0 1px rgba(16,20,24,0.18)\" }}\n        >\n          {paused ? \"Play motion\" : \"Pause motion\"}\n        </button>\n      )}\n    </section>\n  );\n}\n","type":"registry:component","target":"components/viberdy/contrary-shadow-section.tsx"}],"meta":{"tier":"free","tags":["manifesto","typography","shadow","lighting","pointer","anomaly","attention","light mode","text effect"],"href":"https://viberdy.dev/library/contrary-shadow-section","aiPrompt":"Add \"ContraryShadowSection\" to this app: a manifesto section set as mounted letters on a lit plaster wall. Every word casts a shadow away from a small hanging lamp, except one word, whose shadow falls toward it. The anomaly is a shadow that runs to its light.\n\nHow it works:\n- The wall is a pale colour (`wall`, default #d3d6d4) with a static plaster grain and a pool of lamplight (a radial gradient centred on the lamp through --lx and --ly, darkening toward the edges).\n- The lamp is drawn: a thin cord from the top edge and a small shade with a glowing bulb. Its pivot rests at 24% of the width (30% on narrow screens) and follows a mouse pointer with a 0.35 s lag; touch drags it. It swings slowly on an 11.3 s period.\n- The statement is split into words (inline-block spans with real spaces between them). Each frame every word's shadow is computed from the lamp as a point light 700px off the wall, with the letters standing `standoff` px off it (default 14): offset = (word - lamp) × standoff / (700 - standoff), blur 1.5 + 0.38 × length, opacity falling with length from 0.5 to 0.2, plus a tight contact shadow and a 1px lit top edge, all written as text-shadow.\n- The `contrary` word (default \"wrong\", matched without punctuation or case) gets the opposite offset, so its shadow points at the lamp.\n- A mono placard shows the eyebrow, where the lamp is (left of centre, centre, right of centre) and how many shadows run each way.\n\nProps: `statement`, `contrary`, `eyebrow`, `body`, `standoff` (4-24), `swing`, `pauseControl` (default on), `wall`, `children`, `className`, `style`.\n\nRules: one loop writes styles through refs and runs only while the lamp moves and the section is on screen; positions are re-measured on resize and when fonts load. Reduced motion (or Pause) holds the lamp still with every shadow settled.","license":"https://viberdy.dev/license"}}