Skip to content

Contrary Shadow Section

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.

FreeAttentionminimal
Preview

Fig. 02 / The studioLamp: left of centreShadows: 5 cast away / 1 cast toward

Every room has one thing wrong.

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.

Props

14
Swing
Pause control
No runtime dependencies
"use client";

import { useEffect, useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";

/**
 * ContraryShadowSection — mounted letters on a lit wall; every shadow runs from the lamp but one.
 *
 * The statement is set as dark letters standing off a pale plaster wall,
 * lit by a small lamp on a cord. Every word casts a shadow computed each
 * frame from the lamp's position (a point light 700px off the wall): the
 * further a word is from the lamp, the longer and softer its shadow. One
 * word, chosen by `contrary`, casts its shadow toward the lamp instead. The
 * lamp swings slowly (11.3 s) under a pivot that follows a mouse pointer
 * with a 0.35 s lag; touch drags it. A pool of lamplight on the wall follows
 * it, and a placard reads where the lamp is and how many shadows run each
 * way. It has a Pause control; reduced motion holds the lamp still with
 * every shadow settled.
 *
 * Part of the Attention kit: a style of anomalies, familiar things made wrong
 * (fish in dry air, a stone that breaks upward, a shadow that runs toward its
 * lamp). One subject, one motivated light, one broken rule, and everything
 * else calm. Cut byte for byte from the kit's one site (AttentionSite, a
 * demo studio called Aerium). Paste it as its own file: it carries its own
 * copy of every helper it uses and has no dependencies.
 */

const HEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;

/** The user's reduced-motion setting, live. Server and first render say "no". */
function useAReducedMotion(): boolean {
  const [reduced, setReduced] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const on = () => setReduced(mq.matches);
    on();
    mq.addEventListener("change", on);
    return () => mq.removeEventListener("change", on);
  }, []);
  return reduced;
}

/** The kit's type: Instrument Sans (through --font-instrument) over Geist. */
const A_SANS = 'var(--font-instrument, "Instrument Sans"), "Geist", system-ui, sans-serif';

/** A static plaster grain (fixed texture, never animated). */
const A_GRAIN =
  "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\")";

export interface ContraryShadowSectionProps {
  /** The statement, set as mounted letters. */
  statement?: string;
  /** The word whose shadow falls toward the lamp (matched without punctuation, any case). */
  contrary?: string;
  /** Small line above the statement. */
  eyebrow?: string;
  /** The paragraph under it. */
  body?: string;
  /** How far the letters stand off the wall, 4-24px: longer, softer shadows. */
  standoff?: number;
  /** The lamp swings slowly on its cord (11.3 s); off holds it still. */
  swing?: boolean;
  /** Show a Pause control for the swing. */
  pauseControl?: boolean;
  /** Wall colour. */
  wall?: string;
  /** Extra content under the paragraph. */
  children?: ReactNode;
  className?: string;
  style?: CSSProperties;
}

/**
 * ContraryShadowSection — mounted letters on a lit wall; every word's shadow
 * runs from the lamp except one, which runs toward it.
 */
export function ContraryShadowSection({
  statement = "Every room has one thing wrong.",
  contrary = "wrong",
  eyebrow = "Fig. 02 / The studio",
  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.",
  standoff = 14,
  swing = true,
  pauseControl = true,
  wall = "#d3d6d4",
  children,
  className = "",
  style,
}: ContraryShadowSectionProps) {
  const hostRef = useRef<HTMLElement>(null);
  const lampRef = useRef<HTMLDivElement>(null);
  const cordRef = useRef<HTMLDivElement>(null);
  const readRef = useRef<HTMLSpanElement>(null);
  const [paused, setPaused] = useState(false);
  const reduced = useAReducedMotion();
  const words = statement.split(/\s+/).filter(Boolean);
  const key = contrary.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
  const odd = words.findIndex((w) => w.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "") === key);
  const D = Math.min(24, Math.max(4, Number.isFinite(standoff) ? standoff : 14));
  const opts = useRef({ D, swing, paused, reduced });
  const kick = useRef<() => void>(() => {});
  useEffect(() => {
    opts.current = { D, swing, paused, reduced };
    kick.current();
  }, [D, swing, paused, reduced]);

  useEffect(() => {
    const host = hostRef.current;
    if (!host) return;
    let W = 1;
    let H = 1;
    let marks: { el: HTMLElement; x: number; y: number; odd: boolean }[] = [];
    const measure = () => {
      const r = host.getBoundingClientRect();
      W = Math.max(1, r.width);
      H = Math.max(1, r.height);
      marks = [...host.querySelectorAll<HTMLElement>("[data-a-word]")].map((el) => {
        const b = el.getBoundingClientRect();
        return { el, x: b.left - r.left + b.width / 2, y: b.top - r.top + b.height / 2, odd: el.dataset.aWord === "odd" };
      });
    };
    // The lamp hangs from a pivot on the ceiling line; the pivot follows the pointer, the lamp swings under it.
    let pivot = -1;
    let pivotGoal = -1;
    let pointer = false;
    let raf = 0;
    let last = 0;
    let onScreen = true;
    let lastSide = "";
    const t0 = performance.now();
    const frame = (now: number) => {
      raf = 0;
      const o = opts.current;
      const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
      last = now;
      const still = o.reduced || o.paused || !o.swing;
      const rest = W * (W < 768 ? 0.3 : 0.24);
      if (pivot < 0) pivot = pivotGoal = rest;
      if (!pointer) pivotGoal = rest;
      // Pointer chase, time constant 0.35 s (instant when motion is reduced).
      pivot = o.reduced ? pivotGoal : pivot + (pivotGoal - pivot) * (1 - Math.exp(-dt / 0.35));
      const t = (now - t0) / 1000;
      const amp = pointer ? 0.05 : 0.2;
      const theta = still ? 0.12 : amp * Math.sin((t / 11.3) * Math.PI * 2) + 0.06;
      const cord = Math.max(90, H * 0.2);
      const lx = pivot + Math.sin(theta) * cord;
      const ly = Math.cos(theta) * cord;
      // The lamp's height off the wall, in px: the shorter it is, the longer the shadows.
      const Z = 700;
      for (const m of marks) {
        const dx = m.x - lx;
        const dy = m.y - ly;
        const k = o.D / (Z - o.D);
        const sx = (m.odd ? -1 : 1) * dx * k;
        const sy = (m.odd ? -1 : 1) * dy * k;
        const len = Math.hypot(sx, sy);
        const blur = 1.5 + 0.38 * len;
        const a = Math.max(0.2, 0.5 - 0.006 * len);
        m.el.style.textShadow =
          "0 -1px 0 rgba(255,255,255,0.42), " +
          (sx * 0.16).toFixed(1) + "px " + (sy * 0.16).toFixed(1) + "px 1px rgba(12,15,18,0.34), " +
          sx.toFixed(1) + "px " + sy.toFixed(1) + "px " + blur.toFixed(1) + "px rgba(12,15,18," + a.toFixed(3) + ")";
      }
      host.style.setProperty("--lx", ((lx / W) * 100).toFixed(2) + "%");
      host.style.setProperty("--ly", ((ly / H) * 100).toFixed(2) + "%");
      if (lampRef.current) lampRef.current.style.transform = "translate3d(" + (lx - 13).toFixed(1) + "px," + (ly - 4).toFixed(1) + "px,0) rotate(" + (-theta).toFixed(4) + "rad)";
      if (cordRef.current) cordRef.current.style.transform = "translate3d(" + pivot.toFixed(1) + "px,0,0) rotate(" + (-theta).toFixed(4) + "rad)";
      if (cordRef.current) cordRef.current.style.height = cord.toFixed(0) + "px";
      const side = lx < W * 0.4 ? "left of centre" : lx > W * 0.6 ? "right of centre" : "centre";
      if (side !== lastSide && readRef.current) {
        lastSide = side;
        readRef.current.textContent = "Lamp: " + side;
      }
      const moving = Math.abs(pivotGoal - pivot) > 0.3;
      if (onScreen && !document.hidden && (!still || moving)) raf = requestAnimationFrame(frame);
      else last = 0;
    };
    kick.current = () => {
      if (!raf) raf = requestAnimationFrame(frame);
    };
    const toLocal = (e: PointerEvent) => {
      const r = host.getBoundingClientRect();
      pivotGoal = Math.min(W * 0.94, Math.max(W * 0.06, e.clientX - r.left));
    };
    const onMove = (e: PointerEvent) => {
      if (e.pointerType === "mouse" || e.buttons) {
        pointer = true;
        toLocal(e);
        kick.current();
      }
    };
    const onDown = (e: PointerEvent) => {
      pointer = true;
      toLocal(e);
      kick.current();
    };
    const onLeave = (e: PointerEvent) => {
      if (e.pointerType === "mouse" || e.type === "pointercancel") {
        pointer = false;
        kick.current();
      }
    };
    const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => {
      measure();
      kick.current();
    }) : null;
    ro?.observe(host);
    const io = typeof IntersectionObserver !== "undefined" ? new IntersectionObserver((es) => {
      onScreen = es.some((e) => e.isIntersecting);
      if (onScreen) {
        measure();
        kick.current();
      }
    }) : null;
    io?.observe(host);
    const onVis = () => kick.current();
    document.addEventListener("visibilitychange", onVis);
    host.addEventListener("pointermove", onMove);
    host.addEventListener("pointerdown", onDown);
    host.addEventListener("pointerleave", onLeave);
    host.addEventListener("pointercancel", onLeave);
    // Fonts change the words' boxes.
    document.fonts?.ready.then(() => {
      measure();
      kick.current();
    });
    measure();
    kick.current();
    return () => {
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
      ro?.disconnect();
      io?.disconnect();
      document.removeEventListener("visibilitychange", onVis);
      host.removeEventListener("pointermove", onMove);
      host.removeEventListener("pointerdown", onDown);
      host.removeEventListener("pointerleave", onLeave);
      host.removeEventListener("pointercancel", onLeave);
      kick.current = () => {};
    };
  }, [statement, contrary]);

  const ground = HEX.test(wall) ? wall : "#d3d6d4";
  const sized = /(^|\s)(min-)?h-/.test(className);
  const away = words.length - (odd >= 0 ? 1 : 0);
  return (
    <section
      ref={hostRef}
      className={"@container relative isolate flex flex-col overflow-hidden" + (sized ? "" : " min-h-[620px]") + (className ? " " + className : "")}
      style={{ backgroundColor: ground, fontFamily: A_SANS, touchAction: "pan-y pinch-zoom", ...style }}
    >
      <div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ backgroundImage: A_GRAIN, opacity: 0.11, mixBlendMode: "multiply" }} />
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0"
        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%)" }}
      />
      {/* The lamp: a cord from the ceiling line and a small shade with its bulb. */}
      <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)" }} />
      <div ref={lampRef} aria-hidden="true" className="pointer-events-none absolute top-0 left-0 h-[22px] w-[26px] origin-[13px_4px]">
        <span className="absolute inset-x-[3px] top-[2px] h-[12px] rounded-t-[13px] rounded-b-[2px]" style={{ background: "linear-gradient(180deg,#2a3036,#14181c)" }} />
        <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)" }} />
      </div>
      <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)]">
        <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">
          <span>{eyebrow}</span>
          <span ref={readRef}>Lamp: left of centre</span>
          <span>
            Shadows: {away} cast away{odd >= 0 ? " / 1 cast toward" : ""}
          </span>
        </p>
        <h2 className="m-0 max-w-[15ch] text-[max(44px,8.4cqw)] leading-[0.98] font-[500] tracking-[-0.045em] text-[#101418]">
          {words.map((w, i) => (
            <span key={i}>
              <span data-a-word={i === odd ? "odd" : ""} className="inline-block">
                {w}
              </span>
              {i < words.length - 1 ? " " : ""}
            </span>
          ))}
        </h2>
        <p className="m-0 max-w-[52ch] text-[max(15px,1.2cqw)] leading-[1.55] text-[#101418]/78">{body}</p>
        {children}
      </div>
      {pauseControl && swing && !reduced && (
        <button
          type="button"
          aria-pressed={paused}
          onClick={() => setPaused((p) => !p)}
          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]!"
          style={{ background: "rgba(255,255,255,0.32)", boxShadow: "inset 0 0 0 1px rgba(16,20,24,0.18)" }}
        >
          {paused ? "Play motion" : "Pause motion"}
        </button>
      )}
    </section>
  );
}

Free, yours to ship. Pro adds the heroes, WebGL backgrounds and complete style kits of Attention, from $19/month (launch price until 31 Oct).

Notes

About this section

A statement set like signage: dark letters standing off a pale plaster wall, lit by a small lamp hanging on a cord. Every word throws a shadow away from the lamp, longer and softer the further it is, and as the lamp swings or follows your pointer along the ceiling all the shadows swing with it. All but one. The contrary word, "wrong" by default, throws its shadow toward the lamp, which the eye catches before it has read the sentence. A placard reads where the lamp is and how many shadows run each way. Touch drags the lamp.

Curator’s note

Each word's shadow is projected from a point light 700px off the wall, so length and softness grow with distance from the lamp, and one word simply takes the opposite offset. The eye knows where shadows go long before it reads, which is why the wrong one stops it. Cut byte for byte from the Attention kit's one site (AttentionSite), so it reads as one system with the kit's rooms: one subject, one motivated light, one broken rule.

Related

Pairs well with

Matched on shared tags and category — the entries most likely to be used alongside this one.

New

A real text input whose typed words hang above the field and cast a soft shadow on its lit shelf; focus lifts them higher and submitting lands them.

inputformtext field
Pro

A footer that is a concrete floor lit edge to edge by water light from a ceiling you cannot see, with rounded top corners. Sharks pass through it, in every direction, and there are no sharks, only their shadows. They swim like real animals: they can only move along their heading. A resting pointer is noticed and avoided or inspected, a click makes them bolt away smoothly, and hovering a link slows the nearest one. Switch to butterfly koi if you prefer.

footersectionwebgl
NewPro

Three plan slabs that lift off a lit floor as the section arrives, each over its own soft shadow; the featured one rises highest and the pointer raises any slab higher.

pricingpricing cardsplans