Hero reveal with AI

The effect behind this site's hero: my real photo on the surface, and under your cursor my AI-native mode peeks through a circle of light. Two twin images from ChatGPT, a 10-line script and CSS that runs on the GPU.

Live workflow2 twin images30 minuteszero libraries

The demo

Wen López, hero base photo
The site's two real twins. Move your cursor (or your finger) over the photo.

The system

Images
ChatGPTreveal with identity lock + base twin
Alignment
Python + Pillowresize + crop, fixes the 5% offset
Effect
CSS clip-pathcircle that crops the AI layer
Cursor lerprequestAnimationFrame, 0.14 smoothing
Result
This site's herobase visible, AI mode under the cursor

The tools

ChatGPTimages

Generates both twins using my real photo as the reference. The identity lock lives in the prompt.

Python + Pillowalignment

10 lines that fix the shifted framing. The step nobody tells you about.

CSS clip-patheffect

The spotlight that follows the cursor. Runs on the GPU, not a single library.

Replicate it step by step

The code, the connections and the mistakes I already made for you.

  1. Generate the reveal with an identity lock

    The image that peeks under the cursor comes from ChatGPT, using your real photo as the reference. The key is the identity lock: you forbid it from touching the person and only allow it to transform the world around them. This is the real prompt I used:

    prompt · reveal image (ChatGPT)
    Use the attached image as the reference. Keep the woman EXACTLY as she is:
    do not change her face, skin, hair, expression, body, pose, outfit, the phone
    in her hands, or the framing and crop. Her identity must stay 100% identical
    to the reference, same person, untouched.
    
    Only transform the WORLD around her into "AI-native command mode": the
    background city dissolves into a luminous, elegant orchestration of growth
    dashboards, flowing data streams and AI workflow nodes (minimal, not cluttered).
    Brand palette only: warm orange (#fe6f01), magenta-pink and soft lavender,
    glowing over a cream base. A subtle glowing letter "W" emblem of light hovers
    beside her as her insignia. Soft orange and magenta rim light along the edge of
    her blazer and hair. Cinematic, premium, optimistic, semi-realistic editorial
    quality. Keep the same vertical 2:3 crop and the negative space on the left.
    No text, no logos, no watermark.
    
    Negative prompt: cartoon, flat illustration, anime, distorted face,
    extra fingers, deformed hands, cluttered, busy background, text, logos,
    watermark, low quality, blurry face
  2. Ask for the base twin

    For the effect to work, the visible image and the hidden one must be twins: same person, same pose, same framing. The trick is asking the same model for both, in the same session and with the same lock, so the grain and rendering match. Only the world changes:

    prompt · base twin (ChatGPT)
    Use the attached image as the reference. Keep the woman EXACTLY as she is:
    do not change her face, skin, hair, expression, body, pose, outfit, the phone
    in her hands, or the framing and crop. Her identity must stay 100% identical
    to the reference, same person, untouched.
    
    Render the SAME photo with no effects: keep the world natural and calm,
    soft warm light over a cream base, premium semi-realistic editorial quality.
    No dashboards, no data streams, no glow, no emblem. Keep the same vertical
    2:3 crop and the negative space on the left. No text, no logos, no watermark.
  3. Align the twins with 10 lines of Pillow

    Even if the prompt says “same framing”, ChatGPT never returns the exact crop: mine came back with a 5% offset and the face jumped when the cursor passed over it. I fixed it with a mini Pillow script: scale and crop the base until both images are pixel-perfect.

    python · align.py
    # align.py · aligns the base twin with the reveal (Pillow)
    # ChatGPT returned the base ~5% off: fix it with resize + crop.
    from PIL import Image
    
    REF = "reveal.png"   # the image that rules (the correct framing)
    SRC = "base.png"     # the misaligned twin
    SCALE = 1.05         # correction factor (my case: 5%)
    DX, DY = 0, -18      # fine adjustment in pixels (by eye, iterating)
    
    ref = Image.open(REF)
    src = Image.open(SRC).convert("RGB")
    
    # 1. Scale the base by the correction factor
    w, h = ref.size
    big = src.resize((round(w * SCALE), round(h * SCALE)), Image.LANCZOS)
    
    # 2. Crop the center (plus the fine adjustment) to the reveal's exact size
    left = (big.width - w) // 2 + DX
    top = (big.height - h) // 2 + DY
    big.crop((left, top, left + w, top + h)).save("base-aligned.png")
  4. Mount it: clip-path + cursor lerp

    Two stacked images. The top one (AI mode) gets cropped by a circular clip-path that follows the cursor with smoothing (lerp) inside a requestAnimationFrame. The radius opens fast and closes soft, and everything composites on the GPU:

    html + css + js · the full effect
    <div class="stage">
      <img class="base" src="base-aligned.png" alt="" />
      <img class="reveal" src="reveal.png" alt="" />
    </div>
    
    <style>
      .stage { position: relative; }
      .stage img { position: absolute; inset: 0; width: 100%; }
      .reveal { clip-path: circle(var(--r, 0px) at var(--x) var(--y)); }
      @media (prefers-reduced-motion: reduce) { .reveal { display: none; } }
    </style>
    
    <script>
      const stage = document.querySelector(".stage");
      let rawX = 0, rawY = 0, x = 0, y = 0, r = 0, targetR = 0;
    
      stage.addEventListener("mousemove", (e) => {
        const b = stage.getBoundingClientRect();
        rawX = e.clientX - b.left;
        rawY = e.clientY - b.top;
        targetR = 150; /* spotlight radius */
      });
      stage.addEventListener("mouseleave", () => { targetR = 0; });
    
      (function frame() {
        x += (rawX - x) * 0.14; /* lerp: the smooth travel of the spotlight */
        y += (rawY - y) * 0.14;
        /* opens fast (0.16), closes soft (0.08) */
        r += (targetR - r) * (targetR > r ? 0.16 : 0.08);
        stage.style.setProperty("--x", x.toFixed(1) + "px");
        stage.style.setProperty("--y", y.toFixed(1) + "px");
        stage.style.setProperty("--r", Math.max(0, r).toFixed(1) + "px");
        requestAnimationFrame(frame);
      })();
    </script>
  5. The fallback is design too

    On touch screens the finger plays cursor, and with prefers-reduced-motion the reveal layer turns off and the base photo stays. An effect that makes people dizzy is not a premium effect.

This is a taste of the Wen Escuelita

I'm building the place where I teach you to connect all of this, in Spanish and without the hype. Leave me your email and you get in first.

Keep going with these