Libreria AI

Effetti web

Interface effects pulled apart and rebuilt: what each one is made of, the exact numbers, and a prompt that reproduces it. Scroll-driven transitions, pointer-driven materials, type that arrives one glyph at a time.

Shimmer headline

A gradient sliding behind the letters, clipped to the glyphs. Three CSS properties do the whole thing; the interesting decisions are the timing function and where the gradient loops.

  • Gradient 100deg, 6 stops, first = last
  • background-size 320% 100%
  • Duration 8s linear infinite
Codice
shimmer-headline.html
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Shimmer headline</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body {
    min-height: 100vh;
    display: grid; place-items: center;
    padding: 0 8%;
    background: radial-gradient(90% 80% at 50% 40%, #14141f, #08080d 75%);
    font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif;
    text-align: center;
    overflow: hidden;
  }

  .stack { display: grid; justify-items: center; gap: 16px; }
  .eyebrow {
    font-size: 10.5px; font-weight: 600;
    letter-spacing: 0.22em; text-transform: uppercase;
    color: #ffffff54;
  }
  .sub { font-size: 12.5px; color: #ffffff47; letter-spacing: 0.01em; }

  h1 {
    font-size: clamp(28px, 7vw, 54px);
    font-weight: 700;
    letter-spacing: -0.035em;
    line-height: 1.04;
    text-wrap: balance;

    /* The three lines that matter: paint a gradient as the background,
       clip it to the glyphs, make the text itself transparent.
       The letters are a window; what moves is the cloth behind them. */
    background: linear-gradient(100deg, #0a84ff, #bf5af2, #ff375f, #ff9f0a, #00c2a8, #0a84ff);
    background-size: 320% 100%;
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;

    /* linear, not ease. This is light travelling at a constant speed,
       not a gesture with a beginning and an end — ease would make it
       hesitate at both ends and read as a stutter. */
    animation: shimmer 8s linear infinite;
  }

  /* First and last stop are the same colour (#0a84ff), so travelling the
     full 320% lands exactly back at the start and the seam is invisible. */
  @keyframes shimmer { to { background-position: 320% 0; } }

  @media (prefers-reduced-motion: reduce) {
    h1 { animation: none; background-position: 30% 0; }
  }
</style>

<div class="stack">
  <span class="eyebrow">Gradient text</span>
  <h1>Software with<br>a point of view</h1>
  <span class="sub">the letters stay still — the gradient behind them moves</span>
</div>
Prompt
Animate a headline with a gradient shimmer. Apply a linear-gradient(100deg, ...) with six colour stops where the first and last stop are the same colour, set background-size: 320% 100%, background-clip: text (with the -webkit- prefix) and color: transparent. Animate background-position from 0 to 320% 0 over 8s with a linear timing function, infinite. Do not use ease — the motion must be constant-speed. Under prefers-reduced-motion: reduce, disable the animation and freeze background-position at 30% so the text keeps its colour instead of going transparent.

Aurora drift

Two blurred colour blobs drifting on mismatched periods, so the composition never visibly loops. Two pseudo-elements, no images, no canvas.

  • Blob size 420px / 460px
  • Blur 90px
  • Opacity 0.55
Codice
aurora-drift.html
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Aurora drift</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body {
    min-height: 100vh;
    display: grid; place-items: center;
    background: #fbfbfd;
    font: 600 clamp(22px, 5vw, 34px)/1.1 -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif;
    letter-spacing: -0.025em;
    color: #1d1d1f;
    /* The blobs deliberately overflow the viewport. Without this the page
       grows scrollbars around them. */
    overflow: hidden;
    position: relative;
    isolation: isolate;
  }

  /* Two large blurred blobs, each drifting on its own period.
     The periods are 18s and 22s — deliberately not equal, and not
     multiples of one another. Give both the same duration and the whole
     composition visibly repeats every cycle. */
  body::before, body::after {
    content: "";
    position: absolute; z-index: -1;
    border-radius: 50%;
    filter: blur(90px);
    opacity: 0.55;
    pointer-events: none;
  }
  body::before {
    width: 420px; height: 420px; top: -12%; left: -10%;
    background: radial-gradient(circle, #a6dcf5, transparent 70%);
    animation: drift-a 18s ease-in-out infinite alternate;
  }
  body::after {
    width: 460px; height: 460px; bottom: -14%; right: -12%;
    background: radial-gradient(circle, #f5cde8, transparent 70%);
    animation: drift-b 22s ease-in-out infinite alternate;
  }

  /* Animate transform only. Moving the blob by changing top/left would
     re-run the 90px blur every frame; a transform hands the already-blurred
     layer to the compositor and merely moves it. */
  @keyframes drift-a { to { transform: translate(70px, 50px) scale(1.12); } }
  @keyframes drift-b { to { transform: translate(-80px, -60px) scale(1.08); } }

  @media (prefers-reduced-motion: reduce) {
    body::before, body::after { animation: none; }
  }
</style>

<p>Quiet light, moving slowly.</p>
Prompt
Create an ambient background using two pseudo-elements on a container. Each is a circle (420px and 460px) filled with a radial-gradient from a pastel colour to transparent at 70%, with filter: blur(90px) and opacity 0.55, positioned so they overflow opposite corners of the container. Animate each with a different period — 18s and 22s, ease-in-out, infinite alternate — moving them by no more than 80px with translate() and scaling to at most 1.12. Animate transform only, never top/left. Set overflow: hidden and isolation: isolate on the container, put the blobs at z-index: -1, and disable both animations under prefers-reduced-motion: reduce.

Magnetic button

The button leans toward the cursor before you reach it, then springs back when you leave. Costs two CSS variables and a pointermove listener.

  • Catchment radius 60px beyond the button box
  • Pull strength 0.35 × offset from centre
  • Release 450ms cubic-bezier(0.23, 1, 0.32, 1)
Codice
magnetic-button.html
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Magnetic button</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body {
    min-height: 100vh;
    display: grid; place-items: center;
    background: #0b0b12;
    font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
    color: #fff;
    overflow: hidden;
    position: relative;
  }
  /* Faint dot grid + a pool of light under the button. Both are decoration:
     they give the pull something to move against, so a 14px offset reads as
     movement instead of as a button that was always slightly off-centre. */
  body::before {
    content: ""; position: absolute; inset: 0;
    background-image: radial-gradient(#ffffff14 1px, transparent 1px);
    background-size: 22px 22px;
    mask-image: radial-gradient(60% 60% at 50% 50%, #000 40%, transparent 100%);
    -webkit-mask-image: radial-gradient(60% 60% at 50% 50%, #000 40%, transparent 100%);
  }
  body::after {
    content: ""; position: absolute; left: 50%; top: 50%;
    width: 320px; height: 320px; margin: -160px 0 0 -160px;
    border-radius: 50%;
    background: radial-gradient(circle, #4b6bff33, transparent 68%);
    filter: blur(18px);
    pointer-events: none;
  }

  .stack {
    position: relative; z-index: 1;
    display: grid; justify-items: center; gap: 20px;
  }
  .eyebrow {
    font-size: 10.5px; font-weight: 600;
    letter-spacing: 0.22em; text-transform: uppercase;
    color: #ffffff5c;
  }
  .hint { font-size: 12px; color: #ffffff47; letter-spacing: 0.01em; }

  .magnet {
    /* JS only writes these two variables; the transform stays declarative.
       That leaves :active free to own `scale` without the two fighting
       over the same property. */
    --mx: 0px;
    --my: 0px;
    position: relative;
    padding: 15px 32px;
    border: 1px solid #ffffff2e;
    border-radius: 99px;
    background:
      linear-gradient(#ffffff1f, #ffffff08) padding-box,
      #14141d;
    color: #fff;
    font: 600 15px/1 inherit;
    letter-spacing: 0.01em;
    cursor: pointer;
    box-shadow:
      0 1px 0 #ffffff21 inset,          /* top edge catches the light */
      0 10px 30px #00000059,
      0 0 0 0 #4b6bff00;                /* glow, grown on hover */
    transform: translate(var(--mx), var(--my));
    /* Only the release should be animated. While tracking the pointer,
       a transition adds lag and the button feels like it is on elastic. */
    transition:
      transform 450ms cubic-bezier(0.23, 1, 0.32, 1),
      box-shadow 300ms ease,
      border-color 300ms ease;
  }
  .magnet.tracking { transition-duration: 0ms, 300ms, 300ms; }
  .magnet:hover {
    border-color: #ffffff4d;
    box-shadow:
      0 1px 0 #ffffff2e inset,
      0 12px 34px #00000066,
      0 0 34px 2px #4b6bff4d;
  }
  .magnet:active { scale: 0.96; }
  .magnet:focus-visible { outline: 2px solid #7f9bff; outline-offset: 4px; }

  @media (prefers-reduced-motion: reduce) {
    .magnet { --mx: 0px !important; --my: 0px !important; }
  }
</style>

<div class="stack">
  <span class="eyebrow">Magnetic</span>
  <button class="magnet">Get in touch</button>
  <span class="hint">move your pointer near it</span>
</div>

<script>
  const el = document.querySelector(".magnet");
  const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
  // Mouse only. A touch screen has no hover: the pointer arrives already pressed.
  const fine = matchMedia("(pointer: fine)").matches;

  if (!reduce && fine) {
    const RADIUS = 60;  // catchment area, in px beyond the button's own box
    const PULL = 0.35;  // travel = 35% of the pointer's offset from centre

    // What we have currently applied. Needed to recover the button's resting
    // position — see the subtraction below.
    let ax = 0, ay = 0;

    const set = (x, y, tracking) => {
      ax = x; ay = y;
      el.classList.toggle("tracking", tracking);
      el.style.setProperty("--mx", x.toFixed(2) + "px");
      el.style.setProperty("--my", y.toFixed(2) + "px");
    };
    const release = () => set(0, 0, false);

    addEventListener("pointermove", (e) => {
      const r = el.getBoundingClientRect();
      // getBoundingClientRect reports the *transformed* box, so measuring the
      // offset against it means measuring against a button that has already
      // moved toward the cursor. Left uncorrected the two chase each other to
      // an equilibrium and the pull collapses to a fraction of PULL — invisible
      // if you test with one jump of the mouse, unmissable with a real one.
      // Subtracting what we applied recovers the resting centre.
      const dx = e.clientX - (r.left + r.width / 2 - ax);
      const dy = e.clientY - (r.top + r.height / 2 - ay);
      const inside =
        Math.abs(dx) < r.width / 2 + RADIUS && Math.abs(dy) < r.height / 2 + RADIUS;

      if (inside) set(dx * PULL, dy * PULL, true);
      else release();
    });

    // The pull is released by the *next* pointermove landing outside the
    // catchment area — but if the pointer leaves the document there is no next
    // event, and the button stays stuck wherever it was last pulled to. Easy to
    // miss on a full page; guaranteed in a small embed, where the edge is close.
    //
    // A null relatedTarget is the signal that the pointer left the document
    // rather than merely crossing into another element inside it. Note this is
    // mouseout, not pointerleave: pointerleave does not fire here.
    document.addEventListener("mouseout", (e) => {
      if (!e.relatedTarget) release();
    });
    // Alt-tabbing away mid-pull would otherwise leave it stuck too.
    addEventListener("blur", release);
  }
</script>
Prompt
Build a magnetic button in vanilla HTML/CSS/JS. On pointermove, if the cursor is within 60px of the button's bounding box, translate the button by 35% of the cursor's offset from the button centre; outside that range translate back to 0,0. Write the offsets into two CSS custom properties (--mx, --my) and apply them with transform: translate(var(--mx), var(--my)) declared in CSS — do not set the transform from JavaScript. While tracking, set transition-duration to 0ms; on release, transition transform over 450ms cubic-bezier(0.23, 1, 0.32, 1). Add :active { scale: 0.96 }. Measure the cursor offset against the button's RESTING centre: getBoundingClientRect returns the already-transformed box, so subtract the translation currently applied before computing the offset, otherwise the button chases the cursor and the pull collapses. Reset to 0,0 both on a pointermove outside the catchment area and on a document-level mouseout whose relatedTarget is null (the pointer leaving the document fires no further pointermove, so without this the button stays stuck). Only enable the effect when matchMedia('(pointer: fine)') matches and prefers-reduced-motion is not 'reduce'.

Section stacking transition

Each section sticks at the top and the next one climbs over it. The one underneath is never pushed — it is covered, dimmed and pushed back 6%, which is what makes a stack read as depth instead of as a list.

  • Mechanism position: sticky; top: 0; height: 100%
  • Corner 18px 18px 0 0
  • Veil solid #05050a → 0.62
Codice
section-stacking-transition.html
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Section stacking transition</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body { height: 100%; }
  body { font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif; }

  .scroller { height: 100%; overflow-y: auto; background: #08080d; }

  /* Each section sticks at the top and the next one scrolls up over it. The
     card underneath never moves — it is covered, not pushed — which is what
     makes the stack read as depth instead of as a list. */
  .sec {
    position: sticky; top: 0;
    height: 100%;
    display: grid; place-items: center;
    border-radius: 18px 18px 0 0;
    overflow: hidden;
    /* Every section needs its own paint layer or the sticky ones bleed into
       each other's rounded corners. */
    isolation: isolate;
  }
  .sec .inner { text-align: center; color: #fff; padding: 0 8%; }
  .sec h2 { font-size: clamp(24px, 5.4vw, 46px); font-weight: 700; letter-spacing: -0.03em; }
  .sec p { margin-top: 10px; font-size: clamp(12px, 1.8vw, 15px); color: #ffffffa8; }
  .sec .n {
    font-size: 11px; letter-spacing: 0.22em; text-transform: uppercase;
    color: #ffffff70; margin-bottom: 12px;
  }
  /* Dimming + a slight push back on the section being covered. Driven from JS
     as --k (0 = fully exposed, 1 = fully covered). */
  .sec .shade {
    position: absolute; inset: 0; background: #05050a;
    opacity: calc(var(--k, 0) * 0.62); pointer-events: none;
  }
  .sec .inner { transform: scale(calc(1 - var(--k, 0) * 0.06)); }

  .p1 { background: radial-gradient(70% 80% at 30% 25%, #7b2ff7, transparent 62%), #12102a; }
  .p2 { background: radial-gradient(70% 80% at 70% 30%, #0a4d8c, transparent 62%), #071427; }
  .p3 { background: radial-gradient(70% 80% at 45% 70%, #e0620d, transparent 62%), #23120a; }
  .p4 { background: radial-gradient(70% 80% at 55% 40%, #00c2a8, transparent 62%), #04211d; }

  .cue {
    position: fixed; left: 50%; bottom: 12px; transform: translateX(-50%);
    font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase;
    color: #ffffff55; pointer-events: none;
  }
</style>

<div class="scroller" id="scroller">
  <section class="sec p1"><div class="inner"><div class="n">01</div><h2>Sections stack</h2><p>Each one sticks while the next climbs over it.</p></div><div class="shade"></div></section>
  <section class="sec p2"><div class="inner"><div class="n">02</div><h2>Nothing is pushed</h2><p>The card underneath stays where it is. It gets covered.</p></div><div class="shade"></div></section>
  <section class="sec p3"><div class="inner"><div class="n">03</div><h2>Depth, not a list</h2><p>Dimming and a 6% scale sell the one below as further away.</p></div><div class="shade"></div></section>
  <section class="sec p4"><div class="inner"><div class="n">04</div><h2>Last one rests</h2><p>Give the stack a floor so it does not end mid-transition.</p></div><div class="shade"></div></section>
</div>
<div class="cue">scroll ↓</div>

<script>
  const scroller = document.getElementById("scroller");
  const secs = [...document.querySelectorAll(".sec")];

  const update = () => {
    const h = scroller.clientHeight;
    secs.forEach((sec, i) => {
      const next = secs[i + 1];
      // How far the next section has climbed over this one, 0 → 1.
      const k = next ? Math.min(1, Math.max(0, 1 - (next.getBoundingClientRect().top / h))) : 0;
      sec.style.setProperty("--k", k.toFixed(3));
    });
  };

  scroller.addEventListener("scroll", update, { passive: true });
  addEventListener("resize", update);
  update();
</script>
Prompt
Build a section stacking transition in vanilla HTML/CSS/JS. Give every full-height section position: sticky; top: 0; height: 100vh; border-radius: 18px 18px 0 0; overflow: hidden and isolation: isolate — the isolation is required or sticky siblings bleed through each other's rounded corners. The section being covered must NOT move: do not translate it. Instead compute, for each section, k = clamp(0, 1 - nextSection.getBoundingClientRect().top / viewportHeight, 1) and use it for two things only — a solid dark overlay at opacity k * 0.62, and scale(1 - k * 0.06) on that section's contents. Derive k from the covering element's own rect rather than from a global scroll offset so it stays correct at any section height. Give the last section no transition so the stack comes to rest. Register the scroll listener with { passive: true }.

Staggered character reveal

Every glyph rises out of its own mask, 26ms apart. The interesting decisions are all in the splitting: per word so the text still wraps, and one aria-label so a screen reader does not read it letter by letter.

  • Stagger 26ms per character
  • Duration 620ms
  • Easing cubic-bezier(0.16, 1, 0.3, 1)
Codice
staggered-character-reveal.html
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Staggered character reveal</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body {
    min-height: 100vh; display: grid; place-items: center;
    padding: 0 9%;
    background: radial-gradient(120% 100% at 20% 0%, #f7f8fa, #e9ecf1 70%);
    font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif;
    color: #12141a;
    overflow: hidden;
  }
  .stack { max-width: 34ch; }

  .eyebrow {
    display: block; margin-bottom: 14px;
    font-size: 10.5px; font-weight: 600;
    letter-spacing: 0.22em; text-transform: uppercase;
    color: #12141a5c;
  }
  h1 {
    font-size: clamp(23px, 5.4vw, 42px);
    font-weight: 700; letter-spacing: -0.032em; line-height: 1.16;
  }
  .sub {
    margin-top: 14px;
    font-size: clamp(12px, 1.7vw, 14.5px); line-height: 1.6; color: #4d545f;
  }

  /* Each word is its own mask. Masking per word rather than per line lets the
     text wrap normally — a single line-level mask breaks the moment the copy
     reflows at a narrower width, which is where this effect usually dies. */
  .w {
    display: inline-block; overflow: hidden;
    vertical-align: top;
    /* Descenders (g, y, p) get clipped by a mask sized to the line box, so the
       mask is padded and pulled back by the same amount. */
    padding-bottom: 0.16em; margin-bottom: -0.16em;
  }
  .c {
    display: inline-block;
    transform: translateY(110%);
    opacity: 0;
  }
  .go .c {
    transform: none; opacity: 1;
    transition:
      transform 620ms cubic-bezier(0.16, 1, 0.3, 1),
      opacity 300ms linear;
    transition-delay: calc(var(--i) * 26ms);
  }

  .rerun {
    position: fixed; right: 14px; bottom: 14px;
    display: inline-flex; align-items: center; gap: 6px;
    font: 500 11.5px/1 inherit; color: #4d545f;
    background: #ffffffd6;
    -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
    border: 1px solid #12141a1f; border-radius: 99px;
    padding: 7px 13px; cursor: pointer;
    transition: color 180ms ease, border-color 180ms ease;
  }
  .rerun::before { content: "↻"; font-size: 13px; line-height: 0.8; }
  .rerun:hover { color: #12141a; border-color: #12141a3d; }

  @media (prefers-reduced-motion: reduce) {
    .c { transform: none; opacity: 1; }
    .go .c { transition: none; }
    .rerun { transition: none; }
  }
</style>

<div class="stack">
  <span class="eyebrow">Staggered reveal</span>
  <h1 data-split>Immersive entertainment that did not exist before</h1>
  <p class="sub" data-split>Each glyph rises out of its own mask, 26ms apart.</p>
</div>
<button class="rerun" id="rerun">Replay</button>

<script>
  const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;

  function split(el) {
    // Words first, characters inside them. Splitting the whole string into
    // characters and letting them wrap would let a line break land mid-word.
    const words = el.textContent.split(/(\s+)/);
    el.textContent = "";
    let n = 0;
    for (const word of words) {
      if (/^\s+$/.test(word)) { el.appendChild(document.createTextNode(word)); continue; }
      const w = document.createElement("span");
      w.className = "w";
      for (const ch of word) {
        const c = document.createElement("span");
        c.className = "c";
        c.style.setProperty("--i", n++);
        c.textContent = ch;
        w.appendChild(c);
      }
      el.appendChild(w);
    }
    // The visible text is now split across dozens of spans, which a screen
    // reader would announce one letter at a time. One aria-label on the parent
    // and the pieces hidden puts it back to a single sentence.
    el.setAttribute("aria-label", el.textContent);
    [...el.querySelectorAll(".w")].forEach((w) => w.setAttribute("aria-hidden", "true"));
    return el;
  }

  const targets = [...document.querySelectorAll("[data-split]")].map(split);

  const play = () => {
    targets.forEach((t) => t.classList.remove("go"));
    // Force a reflow so removing and re-adding the class in the same frame
    // actually restarts the transition instead of being coalesced away.
    void document.body.offsetWidth;
    targets.forEach((t) => t.classList.add("go"));
  };

  if (reduce) targets.forEach((t) => t.classList.add("go"));
  else {
    const io = new IntersectionObserver((es) => {
      if (es.some((e) => e.isIntersecting)) { play(); io.disconnect(); }
    }, { threshold: 0.4 });
    io.observe(targets[0]);
  }
  document.getElementById("rerun").addEventListener("click", play);
</script>
Prompt
Build a staggered character reveal in vanilla HTML/CSS/JS. Split the text into WORDS first and characters inside each word — never split the whole string into characters, or a line break can land mid-word. Wrap each word in a span with overflow: hidden, display: inline-block, vertical-align: top, plus padding-bottom: 0.16em and margin-bottom: -0.16em so descenders are not clipped by the mask. Each character span starts at translateY(110%) and opacity 0; when the parent gets a .go class they animate to none/1 with transition: transform 620ms cubic-bezier(0.16, 1, 0.3, 1), opacity 300ms linear and transition-delay: calc(var(--i) * 26ms), where --i is the running character index. Keep the opacity ramp deliberately shorter than the movement. Set aria-label on the parent to the original sentence and aria-hidden on every generated span, so screen readers read one string rather than individual letters. Trigger on IntersectionObserver at threshold 0.4. For replay, remove the class, force a reflow by reading document.body.offsetWidth, then re-add it. Under prefers-reduced-motion: reduce, render the text in its final state with no transition.

Scroll snap

Flick the row sideways and it settles exactly on the next item. Four CSS declarations, no JavaScript — and the browser keeps its own momentum physics, which is why a hand-written version never feels quite right.

  • Snap x mandatory
  • Align center
  • Scroll padding 22% inline
Codice
scroll-snap-gallery.html
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scroll snap</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body { height: 100%; }
  body {
    font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif;
    background: #08080d; color: #fff;
    display: grid; grid-template-rows: 1fr auto; height: 100%;
  }

  /* The whole effect is these four declarations. No JS, no libraries, and the
     browser keeps its own momentum physics — which is why it feels native and
     a hand-written snap never quite does. */
  .rail {
    display: flex; gap: 16px;
    overflow-x: auto;
    scroll-snap-type: x mandatory;      /* 1: this axis snaps, always */
    scroll-padding-inline: 22%;         /* 3: where "centred" actually is */
    scroll-behavior: smooth;
    padding: 22px 22%;
    align-items: center;
    scrollbar-width: none;
  }
  .rail::-webkit-scrollbar { display: none; }

  .slide {
    /* 🩸 A flex-basis percentage resolves against the container's CONTENT box,
       which the 22% padding above has already taken a bite out of. `56%` here
       would silently give you 56% of 56% — about a third of the rail — and the
       slides come out half the size you asked for with nothing to explain why.
       100% of the content box is exactly the 56% of the rail we actually want,
       and it leaves the neighbours peeking by exactly the padding. */
    flex: 0 0 100%;
    aspect-ratio: 16 / 10;
    border-radius: 12px;
    scroll-snap-align: center;          /* 2: which part of the child lines up */
    position: relative; overflow: hidden;
    /* 4: don't let a fast flick skip past a slide. `proximity` would allow it. */
    scroll-snap-stop: always;
    transition: transform 320ms cubic-bezier(0.23, 1, 0.32, 1);
  }
  .slide .tag {
    position: absolute; left: 12px; bottom: 10px;
    font-size: 12px; font-weight: 600; text-shadow: 0 1px 8px #000a;
  }
  .s1 { background: radial-gradient(60% 80% at 30% 25%, #ff5abf, transparent 60%), linear-gradient(140deg,#7b2ff7,#1a1030); }
  .s2 { background: radial-gradient(60% 80% at 70% 30%, #29e0ff, transparent 60%), linear-gradient(140deg,#0a4d8c,#071427); }
  .s3 { background: radial-gradient(60% 80% at 40% 70%, #ffd23e, transparent 60%), linear-gradient(140deg,#e0620d,#2a1206); }
  .s4 { background: radial-gradient(60% 80% at 55% 35%, #9a8bff, transparent 60%), linear-gradient(140deg,#3b2bff,#0d0a24); }
  .s5 { background: radial-gradient(60% 80% at 35% 45%, #00c2a8, transparent 60%), linear-gradient(140deg,#0b6b5c,#04211d); }

  .foot {
    padding: 0 22px 16px;
    display: flex; align-items: center; justify-content: space-between;
    font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; color: #ffffff5c;
  }
  .dots { display: flex; gap: 6px; }
  .dots i { width: 5px; height: 5px; border-radius: 50%; background: #ffffff30; transition: background 200ms; }
  .dots i.on { background: #fff; }

  @media (prefers-reduced-motion: reduce) {
    .rail { scroll-behavior: auto; }
    .slide { transition: none; }
  }
</style>

<div class="rail" id="rail">
  <div class="slide s1"><span class="tag">Run for Money</span></div>
  <div class="slide s2"><span class="tag">Shin Sekai</span></div>
  <div class="slide s3"><span class="tag">Matsuken Samba</span></div>
  <div class="slide s4"><span class="tag">Wear Go Land</span></div>
  <div class="slide s5"><span class="tag">Discoat 2025SS</span></div>
</div>
<div class="foot">
  <span>drag sideways · it locks</span>
  <span class="dots" id="dots"></span>
</div>

<script>
  // Everything below is only the dots. The snapping above needs no JavaScript.
  const rail = document.getElementById("rail");
  const dots = document.getElementById("dots");
  const slides = [...rail.children];
  slides.forEach(() => dots.appendChild(document.createElement("i")));

  const mark = () => {
    const mid = rail.scrollLeft + rail.clientWidth / 2;
    let best = 0, bestD = Infinity;
    slides.forEach((s, i) => {
      const d = Math.abs(s.offsetLeft + s.offsetWidth / 2 - mid);
      if (d < bestD) { bestD = d; best = i; }
    });
    [...dots.children].forEach((d, i) => d.classList.toggle("on", i === best));
  };
  rail.addEventListener("scroll", mark, { passive: true });
  mark();
</script>
Prompt
Build a horizontal snapping gallery in pure CSS with no JavaScript for the snapping itself. On the flex scroll container set overflow-x: auto, scroll-snap-type: x mandatory (not proximity), scroll-padding-inline: 22% and scroll-behavior: smooth, with padding-inline of 22% so the first and last items can reach the centre. On each slide set flex: 0 0 100% — NOT 56%: a flex-basis percentage resolves against the container's content box, which the padding has already reduced, so 100% of that content box is the 56% of the rail you actually want — plus scroll-snap-align: center and scroll-snap-stop: always so a fast flick advances exactly one item. Hide the scrollbar with scrollbar-width: none and ::-webkit-scrollbar { display: none }. Do not reimplement momentum or snapping in a wheel handler — the browser's own physics is the point. Any JavaScript should be limited to secondary UI such as pagination dots, which find the nearest slide centre to the container's scroll midpoint. Set scroll-behavior: auto under prefers-reduced-motion: reduce.

Scroll-driven 3D carousel

Panels sit on a cylinder around the viewer and scroll rotates the ring. Each panel's place is set once at build time, so the per-frame cost is a single rotation on the parent.

  • Panels 5
  • Angle step 360 / N = 72deg
  • Radius (w/2) / tan(step/2) × 1.25
Codice
scroll-3d-carousel.html
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scroll-driven 3D circular carousel</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body { height: 100%; }
  body { font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif; }

  .scroller { height: 100%; overflow-y: auto; background: #07070c; }
  .track { height: 500%; }
  .stage {
    /* 🩸 100vh,不是 100%。`.track` 高 320~500%,所以 `height: 100%`
       会把舞台撑成整条轨道那么高,内容居中到看不见的地方去(卡片会出现在
       视口下方三分之一处、被切掉)。数值照样在变,所以只验机制是发现不了的。 */
    position: sticky; top: 0; height: 100vh;
    display: grid; place-items: center;
    overflow: hidden;
    perspective: 1100px;
    background: radial-gradient(65% 60% at 50% 50%, #15152a, #07070c 78%);
  }

  /* The ring itself only ever rotates. Every panel's place on it is fixed at
     build time, which is what keeps the per-frame work to one transform. */
  .ring {
    position: relative; width: 46%; aspect-ratio: 16 / 9;
    transform-style: preserve-3d;
    transform: rotateY(var(--ring, 0deg));
  }
  .cell {
    position: absolute; inset: 0;
    border-radius: 8px; overflow: hidden;
    /* backface-visibility matters here: without it the panels on the far side
       of the ring render mirrored through the near ones and the whole thing
       turns to soup. */
    backface-visibility: hidden;
  }
  .cell .art { position: absolute; inset: 0; }
  .cell .veil { position: absolute; inset: 0; background: #05050a; }
  .cell .tag {
    position: absolute; left: 12px; bottom: 10px;
    font-size: 11px; font-weight: 600; color: #fff; text-shadow: 0 1px 6px #000a;
  }

  .caption {
    position: absolute; left: 6%; bottom: 10%; color: #fff;
  }
  .caption b { display: block; font-size: clamp(14px, 2.2vw, 20px); letter-spacing: -0.015em; }
  .caption i {
    display: block; margin-top: 3px; font-style: normal;
    font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; color: #ffffff7a;
  }
  /* 🩸 每条图注都绝对定位叠在一起(这样才能交叉淡入),但父元素必须自己
     撑出尺寸 —— 只写 `inset: 0` 的话父元素塌成一条线,文字被挤成竖排。
     所以父元素给一个固定高度,子元素只定位左上角并禁止换行。 */
  .caption { min-height: 2.9em; min-width: 12ch; }
  .caption span { display: block; position: absolute; left: 0; top: 0; white-space: nowrap; }
  .cue {
    position: absolute; right: 5%; bottom: 11%;
    font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: #ffffff55;
  }
</style>

<div class="scroller" id="scroller">
  <div class="track">
    <div class="stage">
      <div class="ring" id="ring"></div>
      <div class="caption" id="caption"></div>
      <div class="cue">scroll ↓</div>
    </div>
  </div>
</div>

<script>
  const scroller = document.getElementById("scroller");
  const ring = document.getElementById("ring");
  const caption = document.getElementById("caption");

  const ITEMS = [
    ["Wear Go Land", "stellia", "radial-gradient(60% 80% at 30% 25%, #ff5abf, transparent 60%), linear-gradient(140deg,#7b2ff7,#1a1030)"],
    ["Discoat 2025SS", "exhibition", "radial-gradient(60% 80% at 70% 30%, #29e0ff, transparent 60%), linear-gradient(140deg,#0a4d8c,#071427)"],
    ["Matsuken Samba", "rise up", "radial-gradient(60% 80% at 40% 70%, #ffd23e, transparent 60%), linear-gradient(140deg,#e0620d,#2a1206)"],
    ["Shin Sekai", "nowhere", "radial-gradient(60% 80% at 55% 35%, #9a8bff, transparent 60%), linear-gradient(140deg,#3b2bff,#0d0a24)"],
    ["Run for Money", "fortnite", "radial-gradient(60% 80% at 35% 45%, #00c2a8, transparent 60%), linear-gradient(140deg,#0b6b5c,#04211d)"],
  ];

  const N = ITEMS.length;
  const STEP = 360 / N;      // angle between neighbours
  // Radius from the panel width so neighbours sit edge to edge rather than
  // overlapping or leaving a gap: r = (w/2) / tan(step/2).
  const radiusFor = (w) => (w / 2) / Math.tan((STEP / 2) * Math.PI / 180) * 1.25;

  const cells = ITEMS.map(([title, sub, art], i) => {
    const el = document.createElement("div");
    el.className = "cell";
    el.innerHTML = `<div class="art"></div><div class="veil"></div><div class="tag"></div>`;
    el.querySelector(".art").style.background = art;
    el.querySelector(".tag").textContent = title;
    ring.appendChild(el);

    const cap = document.createElement("span");
    cap.innerHTML = "<b></b><i></i>";
    cap.querySelector("b").textContent = title;
    cap.querySelector("i").textContent = sub;
    caption.appendChild(cap);
    return el;
  });
  const caps = [...caption.children];

  let radius = 0;
  const layout = () => {
    radius = radiusFor(ring.clientWidth);
    cells.forEach((el, i) => {
      el.style.transform = `rotateY(${i * STEP}deg) translateZ(${radius}px)`;
    });
  };

  const update = () => {
    const max = scroller.scrollHeight - scroller.clientHeight;
    const p = max > 0 ? Math.min(1, Math.max(0, scroller.scrollTop / max)) : 0;
    const pos = p * (N - 1);                   // continuous index
    ring.style.setProperty("--ring", (-pos * STEP).toFixed(3) + "deg");

    cells.forEach((el, i) => {
      const d = Math.abs(i - pos);
      el.querySelector(".veil").style.opacity = Math.min(0.8, d * 0.42).toFixed(3);
    });
    caps.forEach((el, i) => {
      const k = Math.min(1, Math.abs(i - pos));
      el.style.opacity = (1 - k).toFixed(3);
      el.style.transform = `translateY(${(i - pos) * 12}px)`;
    });
  };

  layout(); update();
  scroller.addEventListener("scroll", update, { passive: true });
  addEventListener("resize", () => { layout(); update(); });
</script>
Prompt
Build a scroll-driven 3D circular carousel in vanilla HTML/CSS/JS. Put perspective: 1100px on a sticky stage and transform-style: preserve-3d on a ring element inside it. Place each of N panels ONCE at build time with transform: rotateY(i * 360/N deg) translateZ(radius), where radius is computed as (panelWidth / 2) / tan((360/N) / 2 in radians) * 1.25 so neighbours sit edge to edge — never hard-code the radius. Give every panel backface-visibility: hidden, without which the far side of the cylinder renders mirrored through the near side. On scroll, convert progress to a continuous index and set exactly one property: rotateY on the ring, equal to -index * step. Do not recompute the individual panel transforms per frame. Dim each panel with a solid dark overlay whose opacity is min(0.8, distanceFromCentre * 0.42). Recompute the radius on resize. Cross-fade one caption element per panel.

Scroll-driven scene transition

Scroll scrubs between full-bleed scenes: the outgoing panel rotates away in 3D as the next one swings in, with the caption cross-fading rather than being rewritten.

  • Scroll distance 420% of the viewport
  • Perspective 1400px, on the stage
  • Panel offset translateX 68% per slot
Codice
scroll-driven-scene-transition.html
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scroll-driven scene transition</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body { height: 100%; }
  body { font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif; }

  .scroller { height: 100%; overflow-y: auto; background: #08080d; }
  .track { height: 420%; }
  .stage {
    /* 🩸 100vh,不是 100%。`.track` 高 320~500%,所以 `height: 100%`
       会把舞台撑成整条轨道那么高,内容居中到看不见的地方去(卡片会出现在
       视口下方三分之一处、被切掉)。数值照样在变,所以只验机制是发现不了的。 */
    position: sticky; top: 0; height: 100vh;
    overflow: hidden;
    perspective: 1400px;          /* the panels are real 3D, not skewed 2D */
    display: grid; place-items: center;
    background: radial-gradient(70% 60% at 50% 45%, #16162a, #08080d 75%);
  }

  .panel {
    position: absolute;
    width: 62%; aspect-ratio: 16 / 9;
    border-radius: 10px; overflow: hidden;
    transform-style: preserve-3d;
    /* No transition. Every frame's transform is written from scroll position,
       so a transition here would fight the scrub and add lag on fast wheels. */
    will-change: transform, opacity;
  }
  .panel .art { position: absolute; inset: 0; }
  .panel .tag {
    position: absolute; left: 14px; bottom: 12px;
    font-size: 12px; font-weight: 600; letter-spacing: 0.02em; color: #fff;
    text-shadow: 0 1px 8px #000000aa;
  }
  /* Panels away from centre are dimmed by an overlay rather than opacity, so
     the panel keeps its own contrast instead of fading into the background. */
  .panel .veil { position: absolute; inset: 0; background: #05050a; }

  .a1 { background: radial-gradient(60% 80% at 30% 25%, #ff5abf, transparent 60%), linear-gradient(140deg, #7b2ff7, #1a1030); }
  .a2 { background: radial-gradient(60% 80% at 70% 30%, #29e0ff, transparent 60%), linear-gradient(140deg, #0a4d8c, #071427); }
  .a3 { background: radial-gradient(60% 80% at 40% 70%, #ffd23e, transparent 60%), linear-gradient(140deg, #e0620d, #2a1206); }

  .caption {
    position: absolute; left: 6%; bottom: 8%;
    color: #fff; pointer-events: none;
  }
  .caption b { display: block; font-size: clamp(15px, 2.4vw, 22px); letter-spacing: -0.015em; }
  .caption i {
    display: block; margin-top: 4px; font-style: normal;
    font-size: 10.5px; letter-spacing: 0.18em; text-transform: uppercase; color: #ffffff7a;
  }
  /* 🩸 每条图注都绝对定位叠在一起(这样才能交叉淡入),但父元素必须自己
     撑出尺寸 —— 只写 `inset: 0` 的话父元素塌成一条线,文字被挤成竖排。
     所以父元素给一个固定高度,子元素只定位左上角并禁止换行。 */
  .caption { min-height: 2.9em; min-width: 12ch; }
  .caption span { display: block; position: absolute; left: 0; top: 0; white-space: nowrap; }
  .cue {
    position: absolute; right: 5%; bottom: 9%;
    font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: #ffffff55;
  }
</style>

<div class="scroller" id="scroller">
  <div class="track">
    <div class="stage" id="stage">
      <div class="panel" data-i="0"><div class="art a1"></div><div class="veil"></div><div class="tag">Hello, Fortnite</div></div>
      <div class="panel" data-i="1"><div class="art a2"></div><div class="veil"></div><div class="tag">Shin Sekai</div></div>
      <div class="panel" data-i="2"><div class="art a3"></div><div class="veil"></div><div class="tag">Run for Money</div></div>
      <div class="caption" id="caption"></div>
      <div class="cue">scroll ↓</div>
    </div>
  </div>
</div>

<script>
  const scroller = document.getElementById("scroller");
  const panels = [...document.querySelectorAll(".panel")];
  const caption = document.getElementById("caption");
  const TITLES = [
    ["Hello, Fortnite", "KizunaAI"],
    ["Shin Sekai", "RADWIMPS"],
    ["Run for Money", "Created in Fortnite"],
  ];

  // One caption element per scene, cross-faded. Rewriting the text of a single
  // element instead would make it pop between scenes with no overlap.
  TITLES.forEach(([t, s]) => {
    const el = document.createElement("span");
    el.innerHTML = "<b></b><i></i>";
    el.querySelector("b").textContent = t;
    el.querySelector("i").textContent = s;
    caption.appendChild(el);
  });
  const caps = [...caption.children];

  const update = () => {
    const max = scroller.scrollHeight - scroller.clientHeight;
    const p = max > 0 ? Math.min(1, Math.max(0, scroller.scrollTop / max)) : 0;
    // Continuous index: 0 → 2 across the scroll. The fractional part IS the
    // transition, which is what makes it scrubbable in both directions.
    const pos = p * (panels.length - 1);

    panels.forEach((el, i) => {
      const d = i - pos;                       // -1 = gone left, 0 = centred, 1 = waiting right
      const k = Math.min(1, Math.abs(d));
      el.style.transform =
        `translateX(${d * 68}%) rotateY(${d * -26}deg) translateZ(${-k * 190}px)`;
      el.style.zIndex = String(100 - Math.round(k * 100));
      el.querySelector(".veil").style.opacity = (k * 0.68).toFixed(3);
      // Fully hide only what is more than one slot away, so at most three
      // panels are ever composited.
      el.style.visibility = Math.abs(d) > 1.6 ? "hidden" : "visible";
    });

    caps.forEach((el, i) => {
      const k = Math.min(1, Math.abs(i - pos));
      el.style.opacity = (1 - k).toFixed(3);
      el.style.transform = `translateY(${(i - pos) * 14}px)`;
    });
  };

  scroller.addEventListener("scroll", update, { passive: true });
  addEventListener("resize", update);
  update();
</script>
Prompt
Build a scroll-driven 3D scene transition in vanilla HTML/CSS/JS. A scroll container with a 420%-tall track and a sticky stage; put perspective: 1400px on the stage, never on the individual panels. Convert scroll progress into a CONTINUOUS index from 0 to (count − 1) and position every panel from its signed distance d to that index with a single expression: translateX(d * 68%) rotateY(d * -26deg) translateZ(-abs(d) * 190px). Do not use discrete states with CSS transitions — the fractional index is the transition, and that is what makes it scrub both ways. Dim off-centre panels with a solid dark overlay whose opacity rises with abs(d) up to 0.68, not with opacity on the panel itself, so the artwork keeps its contrast. Set visibility: hidden on panels more than 1.6 slots away. Render one caption element per scene and cross-fade them by abs(d); never rewrite the text of a single caption element. Set z-index from abs(d) and register the scroll listener passive.

Fullscreen expansion transition

Scroll opens a small card out to full bleed. The card never scales — the window cut out of it does — so the artwork and type inside stay pixel-exact the whole way.

  • Scroll distance 320% of the viewport
  • Start window inset(26% 32%) round 18px
  • End window inset(0) round 0
Codice
fullscreen-expansion-transition.html
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fullscreen expansion transition</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body { height: 100%; }
  body { font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif; }

  .scroller { height: 100%; overflow-y: auto; background: #0a0a10; scrollbar-width: thin; }
  .track { height: 320%; position: relative; }
  /* 🩸 100vh,不是 100%。`.track` 高 320%,所以 `height: 100%` 会把舞台撑成
     整条轨道那么高,内容居中到看不见的地方去(卡片会出现在视口下方三分之一处、
     被切掉)。数值照样在变,所以只验机制是发现不了的。 */
  .stage { position: sticky; top: 0; height: 100vh; overflow: hidden; }

  /* Background wordmark, parked behind and moving at a different rate so the
     expansion reads as depth rather than as a box getting bigger. */
  .backdrop {
    position: absolute; inset: 0;
    display: grid; place-items: center;
    background: radial-gradient(80% 70% at 50% 40%, #1b1b2e, #0a0a10 70%);
  }
  .backdrop span {
    font-size: clamp(60px, 18vw, 190px); font-weight: 800;
    letter-spacing: -0.05em; color: #ffffff0f; white-space: nowrap;
    transform: translateX(calc(var(--p, 0) * -14%)) scale(calc(1 + var(--p, 0) * 0.25));
  }

  /* The card is ALWAYS full-bleed. What changes is the window cut out of it.
     Scaling a small card up instead would stretch its contents and force a
     counter-scale on every child; clipping leaves the artwork untouched at
     every frame, which is why the type inside stays crisp. */
  .card {
    position: absolute; inset: 0;
    /* 🩸 Do NOT write `--p: 0` here as a "default". JS sets --p on the stage and
       it inherits down; a local declaration SHADOWS the inherited value, so the
       card would sit frozen at 0 forever while --p on the stage animates
       perfectly. Nothing errors and devtools shows both values, one on each
       element. Use a fallback in var() instead — it only applies when the
       property is genuinely unset. */
    clip-path: inset(
      calc((1 - var(--p, 0)) * 26%) calc((1 - var(--p, 0)) * 32%)
      round calc((1 - var(--p, 0)) * 18px)
    );
  }
  .card .art {
    position: absolute; inset: 0;
    background:
      radial-gradient(60% 80% at 25% 20%, #ff5abf, transparent 60%),
      radial-gradient(70% 70% at 80% 75%, #29e0ff, transparent 60%),
      linear-gradient(140deg, #7b2ff7, #1b1b3a);
    /* A slow push-in under the reveal. Ends at 1 so the final frame is a clean
       1:1 render rather than a fractionally scaled one. */
    transform: scale(calc(1.14 - 0.14 * var(--p, 0)));
  }
  .card .label {
    position: absolute; left: 0; right: 0; bottom: 0;
    padding: 18px 22px;
    background: linear-gradient(transparent, #00000099);
    color: #fff;
    opacity: var(--p, 0);
  }
  .card .label b { display: block; font-size: 17px; letter-spacing: -0.01em; }
  .card .label i {
    display: block; margin-top: 3px; font-style: normal;
    font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; color: #ffffff8c;
  }

  .rail {
    position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%);
    width: 108px; height: 3px; border-radius: 99px; background: #ffffff24; overflow: hidden;
  }
  .rail i { display: block; height: 100%; background: #fff; transform-origin: left; transform: scaleX(var(--p, 0)); }
  .cue {
    position: absolute; left: 50%; top: 16px; transform: translateX(-50%);
    font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase;
    color: #ffffff66; opacity: calc(1 - var(--p, 0) * 2);
  }
</style>

<div class="scroller" id="scroller">
  <div class="track">
    <div class="stage" id="stage">
      <div class="backdrop"><span>WORKS</span></div>
      <div class="card">
        <div class="art"></div>
        <div class="label"><b>Wear Go Land</b><i>Fullscreen expansion</i></div>
      </div>
      <div class="cue">scroll ↓</div>
      <div class="rail"><i></i></div>
    </div>
  </div>
</div>

<script>
  const scroller = document.getElementById("scroller");
  const stage = document.getElementById("stage");

  const update = () => {
    const max = scroller.scrollHeight - scroller.clientHeight;
    // Progress is the only thing JS computes. Everything visual is expressed in
    // CSS against --p, so the whole transition can be inspected by setting one
    // number in devtools instead of stepping through a scroll handler.
    const p = max > 0 ? Math.min(1, Math.max(0, scroller.scrollTop / max)) : 0;
    stage.style.setProperty("--p", p.toFixed(4));
  };

  // passive: the handler never calls preventDefault, and saying so lets the
  // browser start scrolling without waiting to find out.
  scroller.addEventListener("scroll", update, { passive: true });
  addEventListener("resize", update);
  update();
</script>
Prompt
Build a scroll-driven fullscreen expansion transition in vanilla HTML/CSS/JS. Use a scroll container with a track 320% of the viewport height and a sticky stage. The media card must be absolutely positioned full-bleed at all times; animate only its clip-path, from inset(26% 32% round 18px) at progress 0 to inset(0 round 0) at progress 1 — do NOT scale a small card up, because that stretches its contents and resamples the type at every intermediate size. In JS compute a single scroll progress value 0→1 and write it to a CSS custom property --p; express every visual change in CSS as a calc() against --p. Give the artwork inside the card a push-in from scale(1.14) to exactly scale(1.00) so the final frame is an unscaled render. Move a background wordmark at a different rate (translateX to −14%, scale to 1.25) so the effect reads as camera movement rather than a growing box. Register the scroll listener with { passive: true }.

Interactive liquid distortion

A tilted band of glass follows the pointer and refracts whatever is behind it, leaning into the direction of travel and straightening again when you stop. No WebGL — a clipped duplicate of the scene does the displacement.

  • Band width 92px (half = 46px)
  • Max lean ±34px top vs bottom
  • Follow easing lerp 0.12/frame (lower = heavier)
Codice
interactive-liquid-distortion.html
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Interactive liquid distortion</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body {
    min-height: 100vh; overflow: hidden;
    background: #07070c;
    font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif;
  }

  .stage { position: relative; height: 100vh; isolation: isolate; cursor: crosshair; }

  /* The scene is written once, then duplicated verbatim into the lens. Anything
     that differs between the two copies shows up as a seam at the lens edge. */
  .scene {
    position: absolute; inset: 0;
    display: grid; place-items: center;
    background:
      radial-gradient(70% 90% at 20% 15%, #3b2bff55, transparent 60%),
      radial-gradient(60% 80% at 85% 80%, #ff2ea855, transparent 60%),
      #07070c;
  }
  .scene h1 {
    font-size: clamp(38px, 11vw, 104px);
    font-weight: 800; letter-spacing: -0.045em;
    color: #fff; line-height: 0.95; text-align: center;
  }
  .scene p {
    margin-top: 10px; text-align: center;
    font-size: 11px; letter-spacing: 0.34em; text-transform: uppercase;
    color: #ffffff66;
  }

  /* The lens is a full-size copy of the scene, clipped to a tilted band.
     Making it full-size and clipping (rather than a small rotated box) means
     the copy inside needs no counter-transform to stay registered with the
     original — the single biggest source of seams in this effect. */
  .lens {
    position: absolute; inset: 0; z-index: 2;
    --x: 50%;          /* band centre, follows the pointer */
    --tilt: 26px;      /* horizontal offset of top vs bottom = the lean */
    --half: 46px;      /* half the band width */
    clip-path: polygon(
      calc(var(--x) - var(--half) + var(--tilt)) 0%,
      calc(var(--x) + var(--half) + var(--tilt)) 0%,
      calc(var(--x) + var(--half) - var(--tilt)) 100%,
      calc(var(--x) - var(--half) - var(--tilt)) 100%
    );
  }
  /* The refraction itself: the copy is scaled and pushed against the lean, so
     whatever sits behind the band appears displaced. This is the whole trick —
     everything else on this page is decoration. */
  .lens .scene {
    transform: scale(1.07) translateX(calc(var(--tilt) * -0.85));
    filter: saturate(1.5) contrast(1.06) brightness(1.08);
  }
  /* Iridescent sheen + the bright edges that sell it as glass. */
  .lens::after {
    content: ""; position: absolute; inset: 0;
    background: linear-gradient(
      100deg,
      #ff3b8b 0%, #ffd93b 22%, #3bffd0 46%, #3b7bff 72%, #b93bff 100%);
    /* `screen` and not `color-dodge`. Dodge divides by the inverse of the
       backdrop, so over near-black — which is most of this scene — it returns
       near-black and the sheen is simply not there. It looks like the gradient
       failed to load. `screen` brightens toward the source colour whatever is
       underneath, which is what "glass catching the light" needs. */
    mix-blend-mode: screen;
    opacity: 0.3;
  }
  .edge {
    position: absolute; top: -5%; height: 110%; width: 1.5px; z-index: 3;
    pointer-events: none;
    /* The two edges get opposite tints. Real chromatic aberration splits the
       channels by wavelength; two coloured hairlines read as the same thing at
       a fraction of the cost. */
    transform-origin: 50% 50%;
  }
  .edge-l { background: linear-gradient(#00e5ff00, #00e5ffcc 30%, #00e5ffcc 70%, #00e5ff00); }
  .edge-r { background: linear-gradient(#ff2ea800, #ff2ea8cc 30%, #ff2ea8cc 70%, #ff2ea800); }

  .hint {
    position: absolute; left: 50%; bottom: 18px; transform: translateX(-50%);
    z-index: 4; font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase;
    color: #ffffff5c; pointer-events: none;
  }

  @media (prefers-reduced-motion: reduce) { .hint { display: none; } }
</style>

<div class="stage" id="stage">
  <div class="scene">
    <div>
      <h1>REFRACT</h1>
      <p>move the pointer</p>
    </div>
  </div>

  <div class="lens" id="lens">
    <div class="scene">
      <div>
        <h1>REFRACT</h1>
        <p>move the pointer</p>
      </div>
    </div>
  </div>

  <i class="edge edge-l" id="edgeL"></i>
  <i class="edge edge-r" id="edgeR"></i>
  <div class="hint">interactive liquid distortion</div>
</div>

<script>
  const stage = document.getElementById("stage");
  const lens = document.getElementById("lens");
  const edgeL = document.getElementById("edgeL");
  const edgeR = document.getElementById("edgeR");
  const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;

  const HALF = 46;        // half band width, px
  const MAX_TILT = 34;    // how far the band can lean, px
  const EASE = 0.12;      // 0-1 per frame: lower = heavier, more liquid

  let targetX = 0, x = 0, tilt = 0, lastX = 0, raf = 0;

  const place = () => {
    lens.style.setProperty("--x", x.toFixed(1) + "px");
    lens.style.setProperty("--tilt", tilt.toFixed(1) + "px");
    lens.style.setProperty("--half", HALF + "px");
    const deg = Math.atan2(tilt * 2, stage.clientHeight) * 180 / Math.PI;
    for (const [el, side] of [[edgeL, -1], [edgeR, 1]]) {
      el.style.left = (x + side * HALF) + "px";
      el.style.transform = `rotate(${-deg}deg)`;
    }
  };

  const tick = () => {
    const prev = x;
    x += (targetX - x) * EASE;
    // The lean comes from velocity, not position: the band trails the pointer
    // and straightens up again once you stop. Sampling velocity per frame here
    // (rather than per pointermove) keeps it stable when events come in bursts.
    const v = x - prev;
    tilt += (Math.max(-MAX_TILT, Math.min(MAX_TILT, v * 3.2)) - tilt) * 0.1;
    place();
    raf = Math.abs(targetX - x) > 0.05 || Math.abs(tilt) > 0.05 ? requestAnimationFrame(tick) : 0;
  };

  const setTarget = (clientX) => {
    targetX = clientX - stage.getBoundingClientRect().left;
    if (!raf) raf = requestAnimationFrame(tick);
  };

  targetX = x = stage.clientWidth / 2;
  place();

  if (reduce) {
    // Still show the lens, just parked and static: the point of the sample is
    // the refraction, and freezing it is better than hiding it entirely.
    tilt = 18; place();
  } else {
    stage.addEventListener("pointermove", (e) => setTarget(e.clientX));
    addEventListener("resize", () => place());
  }
</script>
Prompt
Build an interactive liquid-glass distortion in vanilla HTML/CSS/JS, no WebGL and no libraries. Render the scene twice: the page itself, and an identical full-size copy inside a .lens element that is clipped with clip-path: polygon() to a vertical band 92px wide whose top and bottom edges are offset in opposite directions by a --tilt variable. Do not rotate the lens box — clip a full-size element, so the copy inside needs no counter-transform. Apply transform: scale(1.07) translateX(calc(var(--tilt) * -0.85)) and filter: saturate(1.5) contrast(1.06) to the copy: that offset is the refraction. Overlay a five-stop iridescent linear-gradient at mix-blend-mode: screen (NOT color-dodge, which returns near-black over a dark backdrop and makes the sheen vanish), opacity 0.30, and draw two 1.5px hairlines along the band edges, #00e5ff on the left and #ff2ea8 on the right, rotated to match the lean. In JS, lerp the band's x toward the pointer at 0.12 per animation frame, and derive --tilt from the per-frame velocity (clamped to ±34px, itself eased at 0.1) so the band leans into movement and straightens when the pointer stops — sample velocity once per requestAnimationFrame, never per pointermove event. Under prefers-reduced-motion: reduce, park the band with a fixed tilt instead of hiding it.
All categories