Skip to content
jjswnth/ui

Section

Hero

The jswnth.com hero: a split-text entrance, a scroll wipe, and draggable stickers, held until the page signals it is ready.

Full Stack Developerjswnth.com

Portfolio’26

Say hi!

Hi! I’m Jaswanth

  • Web Developer · Kuwait
  • Full Stack Developer
  • Frontend Engineer
  • Motion & Interaction
  • 3D / Blender
  • Creative Coder
N° 01’26
jswnth2026
React
Astro
Three.js
GSAP
TypeScript
Blender
What’s Up

I'm a web developer in Kuwait City. I write the interface, the motion, and the plumbing underneath — then keep rewriting it until the whole thing feels obvious. Fast pages, honest interactions, and the kind of detail you only notice when it's missing.

Fig. 01The view from here
drag the bits around
Scroll

Installation

  1. 1. Install the dependencies.

    terminal
    npm install gsap clsx tailwind-merge
  2. 2. Add the cn helper, if you don't already have it.

    lib/utils.ts
    import { clsx, type ClassValue } from "clsx";
    import { twMerge } from "tailwind-merge";
    
    export function cn(...inputs: ClassValue[]) {
      return twMerge(clsx(inputs));
    }
  3. 3. Copy the component into your project.

    components/ui/hero.tsx
    "use client";
    
    import "@/styles/portfolio/tokens.css";
    import "@/styles/portfolio/hero.css";
    import { useEffect, useLayoutEffect, useRef } from 'react';
    import { gsap } from 'gsap';
    import { ScrollTrigger } from 'gsap/ScrollTrigger';
    import { SplitText } from 'gsap/SplitText';
    import { Draggable } from 'gsap/Draggable';
    import { InertiaPlugin } from 'gsap/InertiaPlugin';
    
    gsap.registerPlugin(ScrollTrigger, SplitText, Draggable, InertiaPlugin);
    /* Mobile browsers fire a resize event when their address bar hides/shows on
       scroll — by default that makes ScrollTrigger re-measure and re-pin, which
       visibly drops the hero a few seconds in, right as the bar finishes
       collapsing after the intro. This is GSAP's own documented fix for that
       class of jump. */
    ScrollTrigger.config({ ignoreMobileResize: true });
    
    const ROLES = ['Web Developer · Kuwait', 'Full Stack Developer', 'Frontend Engineer', 'Motion & Interaction', '3D / Blender', 'Creative Coder'];
    
    /* die-cut tech stickers tossed around the card — positions are % of the desk */
    const STICKERS = [
      { label: 'React', c: 'var(--color-purposium-blue)', rot: -8, pos: { left: '36%', top: '4%' } },
      { label: 'Astro', c: 'var(--color-orange)', rot: 7, pos: { right: '0%', top: '46%' } },
      { label: 'Three.js', c: 'var(--color-dark-gray)', rot: -5, pos: { right: '9%', bottom: '13%' } },
      { label: 'GSAP', c: 'var(--color-green)', rot: 6, pos: { left: '1%', bottom: '24%' } },
      { label: 'TypeScript', c: 'var(--color-navy-blue)', rot: -3, pos: { left: '40%', bottom: '-1%' } },
      { label: 'Blender', c: 'var(--color-yellow)', t: 'var(--ink)', rot: 9, pos: { left: '0%', top: '46%' } },
    ];
    
    const RING_TEXT = "AVAILABLE FOR WORK • LET'S BUILD • ";
    const POSTMARK_TEXT = 'JSWNTH.COM • HANDMADE WEB • ';
    
    /* Pen scribbles in the margins around the postcard — never on it. Positions
       are percentages of the hero, kept outside the collage's own footprint. */
    type Doodle = {
      id: string;
      box: string;
      w: string;
      pos: React.CSSProperties;
      rot: number;
      c: string;
      sw?: number;
      fill?: boolean;
      /* px the idle drift travels — signed, so edge doodles blow inward rather
         than off the side of the viewport */
      wind?: number;
      d: string[];
    };
    
    const DOODLES: Doodle[] = [
      {
        id: 'wind-l',
        box: '0 0 112 54',
        w: 'clamp(56px, 7vw, 104px)',
        pos: { left: '0.5%', top: '32%' },
        rot: -6,
        c: 'rgba(255,255,255,0.92)',
        wind: 14,
        d: [
          'M2 16C22 4 52 4 74 14c12 6 26 4 30-4 3-6-5-10-8-4',
          'M8 32c22-12 54-12 76-2',
          'M0 46c18-8 40-9 56-4',
        ],
      },
      {
        id: 'wind-r',
        box: '0 0 112 54',
        w: 'clamp(56px, 7vw, 104px)',
        pos: { right: '0.5%', top: '55%' },
        rot: 172,
        c: 'rgba(255,255,255,0.9)',
        wind: -14,
        d: [
          'M2 16C22 4 52 4 74 14c12 6 26 4 30-4 3-6-5-10-8-4',
          'M8 32c22-12 54-12 76-2',
          'M0 46c18-8 40-9 56-4',
        ],
      },
      {
        id: 'spark-1',
        box: '0 0 40 40',
        w: 'clamp(20px, 2.4vw, 34px)',
        pos: { left: '6%', top: '17%' },
        rot: 0,
        c: '#ffffff',
        fill: true,
        d: ['M20 1c2 12 6 17 19 19-13 2-17 7-19 19-2-12-6-17-19-19 13-2 17-7 19-19Z'],
      },
      {
        id: 'spark-2',
        box: '0 0 40 40',
        w: 'clamp(16px, 2vw, 28px)',
        pos: { right: '6.5%', top: '41%' },
        rot: 0,
        c: 'var(--color-orange)',
        fill: true,
        d: ['M20 1c2 12 6 17 19 19-13 2-17 7-19 19-2-12-6-17-19-19 13-2 17-7 19-19Z'],
      },
      {
        id: 'spiral',
        box: '0 0 48 38',
        w: 'clamp(28px, 3.2vw, 46px)',
        pos: { right: '4%', top: '17%' },
        rot: -12,
        c: 'rgba(255,255,255,0.9)',
        d: ['M30 18c0-7-7-11-13-8-8 4-8 18 2 22 12 5 24-4 22-16C39 6 29 0 19 4'],
      },
      {
        id: 'squiggle',
        box: '0 0 68 22',
        w: 'clamp(38px, 4.6vw, 66px)',
        pos: { left: '2.5%', bottom: '31%' },
        rot: -5,
        c: 'rgba(255,255,255,0.88)',
        d: ['M2 11c11-9 22 9 33 0s22 9 31 1'],
      },
      {
        id: 'cloud',
        box: '0 0 50 32',
        w: 'clamp(30px, 3.6vw, 50px)',
        pos: { left: '4.5%', top: '49%' },
        rot: 4,
        c: 'rgba(255,255,255,0.8)',
        d: ['M9 27c-6 0-8-8-2-11-1-9 10-13 16-7 6-6 18-3 18 6 8 1 8 12 0 12H9Z'],
      },
    ];
    
    const SAY_COPY =
      "I'm a web developer in Kuwait City. I write the interface, the motion, and the plumbing underneath — then keep rewriting it until the whole thing feels obvious. Fast pages, honest interactions, and the kind of detail you only notice when it's missing.";
    
    export default function Hero({ ready }: { ready: boolean }) {
      const rootRef = useRef<HTMLElement>(null);
      const wipeRef = useRef<HTMLDivElement>(null);
      const wipeBoxRef = useRef<HTMLDivElement>(null);
      const sweepRef = useRef<SVGLinearGradientElement>(null);
      const ghostRef = useRef<HTMLDivElement>(null);
      const hintRef = useRef<HTMLDivElement>(null);
      const introRef = useRef<gsap.core.Timeline | null>(null);
      const playedRef = useRef(false);
    
      // the postmark is dated the day you visit
      const stampDate = new Date().toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }).toUpperCase();
    
      useLayoutEffect(() => {
        const hero = rootRef.current;
        if (!hero) return;
    
        const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
        const mobile = innerWidth <= 720;
        const $ = <T extends Element = HTMLElement>(sel: string) => Array.from(hero.querySelectorAll<T>(sel));
        const one = <T extends Element = HTMLElement>(sel: string) => hero.querySelector<T>(sel)!;
    
        const bg = one('.hero-bg');
        const scene = one('.hero-scene');
        const card = one('.hero-card');
        const title = one('.hero-title');
        const word = one('.hero-title .word');
        const year = one('.hero-title .year');
        const meta = one('.hero-meta');
        const postmark = one('.hero-postmark');
        const badge = one('.hero-badge');
        const scrollhint = one('.hero-scrollhint');
        const doodleLayer = one('.hero-doodles');
        const doodles = $('.doodle');
        const winds = $('.doodle--wind');
        const hint = hintRef.current;
        const ctaRing = one('.hero-cta-ring path');
        const dragItems = $('.hero-drag');
        const floats = $('.hero-float');
        /* the cursor picks hover states up on its own; this is only for the ones
           it can't see, like "you are currently holding something" */
        const setCursor = (state: string | null) =>
          window.dispatchEvent(new CustomEvent('cursor:state', { detail: { state } }));
    
        let removeMove: (() => void) | undefined;
    
        const ctx = gsap.context((self) => {
          /* ── title: split into chars that boing when brushed ─────────────── */
          const split = new SplitText(word, { type: 'chars', charsClass: 'char' });
          const chars = split.chars as HTMLElement[];
          chars.forEach((ch) => {
            ch.addEventListener('pointerenter', () => {
              gsap
                .timeline()
                .to(ch, { yPercent: -16, rotation: gsap.utils.random(-10, 10), color: 'var(--accent)', duration: 0.22, ease: 'power2.out' })
                .to(ch, { yPercent: 0, rotation: 0, color: 'var(--ink)', duration: 0.9, ease: 'elastic.out(1, 0.35)' });
            });
          });
          title.classList.add('is-revealing');
    
          /* ── idle: paper breathes, wind drifts ────────────────────────────── */
          const startFloat = () => {
            if (reduce) return;
            floats.forEach((el, i) => {
              gsap.to(el, {
                y: gsap.utils.random(6, 12),
                duration: gsap.utils.random(2.2, 3.6),
                yoyo: true,
                repeat: -1,
                ease: 'sine.inOut',
                delay: i * 0.13,
              });
            });
            winds.forEach((el, i) => {
              gsap.to(el, {
                x: Number(el.dataset.drift) || 14,
                opacity: 0.55,
                duration: 2.6 + i * 0.4,
                yoyo: true,
                repeat: -1,
                ease: 'sine.inOut',
              });
            });
          };
    
          /* ── the tidy-up ──────────────────────────────────────────────────
             Leave a piece of paper somewhere it doesn't belong and Jaswanth's
             cursor wanders over, picks it up, and carries it home. Repairs run
             one at a time; grabbing a piece mid-repair calls him off. */
          const ghost = ghostRef.current;
          const draggableOf = new Map<HTMLElement, Draggable>();
          const timers = new Map<HTMLElement, gsap.core.Tween>();
          const queue: HTMLElement[] = [];
          let busy = false;
          let repairing: HTMLElement | null = null;
          let repairTl: gsap.core.Timeline | null = null;
          let settle: (() => void) | null = null;
    
          /* park the arrow tip on the middle of a piece */
          const ghostOn = (el: HTMLElement) => {
            const r = el.getBoundingClientRect();
            gsap.set(ghost, { x: r.left + r.width * 0.5, y: r.top + r.height * 0.5 });
          };
    
          const homeIt = (el: HTMLElement) => {
            gsap.set(el, { x: 0, y: 0, scale: 1 });
            draggableOf.get(el)?.update();
          };
    
          /* Killing a timeline never fires its onComplete, so the queue has to be
             released by hand — otherwise one interrupted repair leaves `busy`
             stuck true and nothing is ever tidied again. */
          const finishRepair = () => {
            repairTl = null;
            repairing = null;
            const done = settle;
            settle = null;
            done?.();
          };
    
          const callOff = () => {
            repairTl?.kill();
            ghost?.classList.remove('is-grabbing');
            gsap.to(ghost, { autoAlpha: 0, duration: 0.2 });
            finishRepair();
          };
    
          const repair = (el: HTMLElement) =>
            new Promise<void>((resolve) => {
              const off = Math.hypot(Number(gsap.getProperty(el, 'x')), Number(gsap.getProperty(el, 'y')));
              if (!ghost || off < 5) return resolve();
    
              /* scrolled past the desk? tidy it silently so it's straight when
                 you come back, rather than performing to an empty room */
              if (Number(gsap.getProperty(scene, 'opacity')) < 0.5) {
                homeIt(el);
                return resolve();
              }
    
              const r = el.getBoundingClientRect();
              const to = { x: r.left + r.width * 0.5, y: r.top + r.height * 0.5 };
              const fromLeft = to.x > innerWidth / 2;
              const enter = fromLeft ? -90 : innerWidth + 90;
    
              repairing = el;
              settle = resolve;
              repairTl = gsap.timeline({ onComplete: finishRepair });
    
              repairTl
                .set(ghost, { x: enter, y: to.y + 120, autoAlpha: 0 })
                .to(ghost, { autoAlpha: 1, duration: 0.22, ease: 'none' }, 0)
                .to(ghost, { x: to.x, y: to.y, duration: 0.75, ease: 'power3.out' }, 0)
                .add(() => ghost.classList.add('is-grabbing'))
                .to(el, { scale: 1.05, duration: 0.18, ease: 'power2.out' })
                /* the ghost rides the piece home rather than running its own
                   tween, so the two can never drift apart */
                .to(el, { x: 0, y: 0, scale: 1, duration: 0.95, ease: 'power2.inOut', onUpdate: () => ghostOn(el) })
                .add(() => {
                  ghost.classList.remove('is-grabbing');
                  draggableOf.get(el)?.update();
                })
                .to(ghost, { x: enter, y: `+=${80}`, autoAlpha: 0, duration: 0.55, ease: 'power2.in' });
            });
    
          const pump = () => {
            if (busy || !queue.length) return;
            const el = queue.shift()!;
            busy = true;
            repair(el).then(() => {
              busy = false;
              pump();
            });
          };
    
          /* debounced per piece: a throw fires onDragEnd then onThrowComplete,
             and we only want to set off after the paper has actually settled */
          const scheduleRepair = (el: HTMLElement) => {
            timers.get(el)?.kill();
            timers.set(
              el,
              gsap.delayedCall(0.85, () => {
                timers.delete(el);
                if (!queue.includes(el)) queue.push(el);
                pump();
              }),
            );
          };
    
          /* ── drag & throw every piece of paper ────────────────────────────── */
          const enableDrag = () => {
            dragItems.forEach((el) => {
              el.dataset.cursor = 'drag';
              const [d] = Draggable.create(el, {
                type: 'x,y',
                bounds: hero,
                inertia: true,
                edgeResistance: 0.82,
                zIndexBoost: true,
                onPress() {
                  timers.get(el)?.kill();
                  timers.delete(el);
                  if (repairing === el) callOff();
                  /* the hint has done its job the moment you grab something */
                  if (hint && !hint.classList.contains('is-done')) {
                    hint.classList.add('is-done');
                    gsap.to(hint, { autoAlpha: 0, y: 10, duration: 0.45, ease: 'power2.in' });
                  }
                  el.classList.add('is-dragging');
                  setCursor('grabbing');
                  gsap.to(el, { scale: 1.06, duration: 0.2, ease: 'power2.out' });
                },
                onRelease() {
                  el.classList.remove('is-dragging');
                  setCursor(null);
                  gsap.to(el, { scale: 1, duration: 0.5, ease: 'elastic.out(1, 0.5)' });
                  /* every release schedules a tidy — a press that never became a
                     drag fires no onDragEnd, and without this a piece grabbed
                     mid-repair and let go would be stranded out of place */
                  scheduleRepair(el);
                },
                onDragEnd() {
                  scheduleRepair(el);
                },
                onThrowComplete() {
                  scheduleRepair(el);
                },
              });
              draggableOf.set(el, d);
            });
          };
    
          const finish = () => {
            title.classList.remove('is-revealing');
            self.add(() => {
              enableDrag();
              startFloat();
            });
            window.dispatchEvent(new CustomEvent('hero:done'));
          };
    
          /* ── intro: the desk gets set ─────────────────────────────────────── */
          const tl = gsap.timeline({ paused: true, defaults: { ease: 'power3.out' }, onComplete: finish });
          tl.from(bg, { scale: 1.14, duration: 2.2, ease: 'power2.out' }, 0)
            .from(card, { y: 120, rotation: -7, autoAlpha: 0, duration: 1.2, ease: 'back.out(1.3)' }, 0.05)
            .from(chars, { yPercent: 115, rotation: 8, duration: 0.85, stagger: 0.045, ease: 'back.out(1.6)' }, 0.55)
            .from(year, { autoAlpha: 0, x: -12, duration: 0.5 }, 1.05)
            .from(meta, { autoAlpha: 0, y: 8, duration: 0.5 }, 1.05)
            /* the postmark slams down and the card takes the hit */
            .fromTo(postmark, { scale: 2.2, autoAlpha: 0, rotation: -34 }, { scale: 1, autoAlpha: 0.88, rotation: -12, duration: 0.36, ease: 'power4.in' }, 1.2)
            .to(card, { y: 5, duration: 0.07, ease: 'power1.in' }, '>-0.02')
            .to(card, { y: 0, duration: 0.6, ease: 'elastic.out(1, 0.4)' })
            /* paper tossed onto the desk from above. On a full desktop scene
               there's room for a 340px swoop timed after the card lands — it
               reads as one more playful beat. On mobile the card already fills
               most of the screen, so that same swoop starts once everything
               else looks finished and then visibly drags the note down across
               it a beat later, which reads as a bug rather than a flourish.
               Settle it in alongside the card instead of after it. */
            .from(
              dragItems,
              mobile
                ? { y: -36, autoAlpha: 0, duration: 0.5, stagger: { each: 0.05, from: 'random' }, ease: 'power2.out' }
                : {
                    y: -340,
                    x: () => gsap.utils.random(-180, 180),
                    rotation: () => gsap.utils.random(-55, 55),
                    autoAlpha: 0,
                    duration: 1.15,
                    stagger: { each: 0.08, from: 'random' },
                    ease: 'back.out(1.1)',
                  },
              mobile ? 0.3 : 0.95,
            )
            .from(doodles, { scale: 0.4, autoAlpha: 0, duration: 0.6, stagger: { each: 0.07, from: 'random' }, ease: 'back.out(2)' }, 1.15)
            /* the ring is drawn with pathLength=1, so the dash maths needs no
               measuring and holds at any size */
            .fromTo(ctaRing, { strokeDashoffset: 1 }, { strokeDashoffset: 0, duration: 0.75, ease: 'power1.inOut' }, 1.5)
            .from('.hero-cta-text', { autoAlpha: 0, y: 8, duration: 0.4 }, 1.45)
            .from('.hero-cta-spark', { scale: 0, rotation: -90, duration: 0.6, ease: 'back.out(2.5)' }, 2.05)
            .from(badge, { scale: 0, rotation: -140, duration: 1.1, ease: 'elastic.out(1, 0.45)' }, 1.6)
            .from(scrollhint, { autoAlpha: 0, y: 10, duration: 0.6 }, 2.3)
            .from(hint, { autoAlpha: 0, y: 14, duration: 0.6 }, 2.4);
          introRef.current = tl;
    
          if (reduce) {
            tl.progress(1, true);
            finish();
          }
    
          /* ── the desk tilts toward the mouse ──────────────────────────────── */
          if (!reduce) {
            const bgX = gsap.quickTo(bg, 'x', { duration: 1, ease: 'power3' });
            const bgY = gsap.quickTo(bg, 'y', { duration: 1, ease: 'power3' });
            const rotX = gsap.quickTo(scene, 'rotationX', { duration: 0.9, ease: 'power3' });
            const rotY = gsap.quickTo(scene, 'rotationY', { duration: 0.9, ease: 'power3' });
            const cardX = gsap.quickTo(card, 'x', { duration: 1.1, ease: 'power3' });
            const onMove = (e: PointerEvent) => {
              if (e.pointerType !== 'mouse') return;
              const nx = (e.clientX / innerWidth - 0.5) * 2;
              const ny = (e.clientY / innerHeight - 0.5) * 2;
              bgX(nx * -18);
              bgY(ny * -12);
              rotY(nx * 4);
              rotX(-ny * 4);
              cardX(nx * 8);
            };
            hero.addEventListener('pointermove', onMove);
            removeMove = () => hero.removeEventListener('pointermove', onMove);
          }
    
          /* ── the whole run of play, inside one pinned hero ─────────────────
             act 1 · the collage holds, drifts, then clears
             act 2 · a clean postcard — the cursive hello writes itself, the copy
                     brightens word by word, the photo lands
             act 3 · the badge swells into a 16:9 slab and swallows the screen */
          const work = document.querySelector<HTMLElement>('#work');
          const wipe = wipeRef.current;
          const box = wipeBoxRef.current;
          const say = one('.hero-say');
          const sayCopy = one('.say-copy');
          const sayPhoto = one('.say-photo');
          const sweep = sweepRef.current;
    
          /* Reduced motion skips the scrubbed run of play below — which is the
             only thing that ever reveals the postcard panel, so it would sit at
             its CSS resting state (opacity 0, the heading fully masked out) and
             that content would be lost for good. Lay it out flat under the
             collage instead, with the hello already written. */
          if (reduce && say) {
            hero.classList.add('is-static');
            gsap.set(say, { autoAlpha: 1 });
            if (sweep) gsap.set(sweep, { attr: { x1: 660, x2: 750 } });
          }
    
          if (!reduce && work && wipe && box) {
            /* layout position, walked through offsetParents so the parallax and
               intro transforms on the badge's ancestors don't skew it */
            const layoutBox = (el: HTMLElement) => {
              let x = 0;
              let y = 0;
              let n: HTMLElement | null = el;
              while (n) {
                x += n.offsetLeft;
                y += n.offsetTop;
                n = n.offsetParent as HTMLElement | null;
              }
              return { x, y, w: el.offsetWidth, h: el.offsetHeight };
            };
    
            /* 16:9, overscanned a touch so its rounded corners sit just off-screen */
            const cover = () => {
              const w = Math.max(innerWidth, (innerHeight * 16) / 9) * 1.04;
              return { w, h: (w * 9) / 16 };
            };
            /* where the slab must sit to land exactly on the badge */
            const onBadge = () => {
              const b = layoutBox(badge as HTMLElement);
              const c = cover();
              return {
                x: b.x + b.w / 2 - innerWidth / 2,
                y: b.y + b.h / 2 - innerHeight / 2,
                sx: b.w / c.w,
                sy: b.h / c.h,
              };
            };
    
            /* The hero pins for the whole sequence, so nothing slides underneath
               and leaks Work early. After a pin, ScrollTrigger parks the element
               at the end of its spacer, so pulling Work up by the hero's own
               height lands its top exactly where the pin releases — whatever the
               pin's length — and the slab never sits on a black dead zone. */
            const syncLayout = () => {
              const c = cover();
              gsap.set(box, { width: c.w, height: c.h });
              work.style.marginTop = `${-hero.offsetHeight}px`;
            };
            syncLayout();
            ScrollTrigger.addEventListener('refreshInit', syncLayout);
            self.add(() => () => {
              ScrollTrigger.removeEventListener('refreshInit', syncLayout);
              work.style.marginTop = '';
            });
    
            /* the copy brightens a word at a time, so it needs word nodes */
            const words = new SplitText(sayCopy, { type: 'words', wordsClass: 'say-word' }).words as HTMLElement[];
    
            /* The cursive hello writes itself on its own clock — the ask was a
               1.5s draw, not a scrubbed one — so it fires once from the timeline
               and ignores any later scrubbing back over that point. */
            let written = false;
            const write = () => {
              if (written || !sweep) return;
              written = true;
              gsap.fromTo(
                sweep,
                { attr: { x1: -90, x2: 0 } },
                { attr: { x1: 660, x2: 750 }, duration: 1.5, ease: 'power1.inOut' },
              );
            };
    
            gsap
              .timeline({
                scrollTrigger: {
                  trigger: hero,
                  start: 'top top',
                  end: '+=280%',
                  pin: hero,
                  pinSpacing: true,
                  scrub: 0.4,
                  invalidateOnRefresh: true,
                  /* Once pinned, ScrollTrigger parks the hero at the END of its
                     spacer — the very slot Work now occupies — and #hero's
                     z-index would keep it painting on top. Hand the stacking
                     order to Work as the pin releases; the slab is at full cover
                     right then, so the swap is invisible. */
                  onLeave: () => work.classList.add('is-front'),
                  onEnterBack: () => work.classList.remove('is-front'),
                },
              })
              /* act 1 — the desk, then it clears */
              .to(bg, { yPercent: 10, ease: 'none', duration: 1 }, 0)
              .to(scene, { yPercent: -8, ease: 'none', duration: 0.3 }, 0)
              .to(scrollhint, { autoAlpha: 0, duration: 0.02, ease: 'none' }, 0.01)
              /* the margin scribbles clear with the collage — screen two is meant
                 to be a clean sheet */
              .to([scene, doodleLayer, hint], { autoAlpha: 0, scale: 0.93, y: -70, duration: 0.1, ease: 'power2.in' }, 0.2)
              /* act 2 — the clean postcard */
              .fromTo(say, { autoAlpha: 0, y: 90, scale: 0.96 }, { autoAlpha: 1, y: 0, scale: 1, duration: 0.1, ease: 'power3.out' }, 0.26)
              .call(write, undefined, 0.3)
              .fromTo(words, { opacity: 0.14 }, { opacity: 1, ease: 'none', duration: 0.05, stagger: { each: 0.0045 } }, 0.4)
              .fromTo(sayPhoto, { autoAlpha: 0, y: 34, scale: 0.96 }, { autoAlpha: 1, y: 0, scale: 1, duration: 0.07, ease: 'power2.out' }, 0.52)
              /* act 3 — the badge hands off to the slab */
              .to(badge, { autoAlpha: 0, duration: 0.02, ease: 'none' }, 0.7)
              .fromTo(box, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.02, ease: 'none' }, 0.7)
              .fromTo(
                box,
                {
                  x: () => onBadge().x,
                  y: () => onBadge().y,
                  scaleX: () => onBadge().sx,
                  scaleY: () => onBadge().sy,
                  borderRadius: '50%',
                },
                { x: 0, y: 0, scaleX: 1, scaleY: 1, borderRadius: '22px', duration: 0.22, ease: 'power2.inOut' },
                0.7,
              )
              /* hold at full cover so a fast scroll can't outrun the scrub */
              .to({}, { duration: 0.08 }, 0.92);
    
            /* Work locks to the top, then the slab dissolves off it. Scaling the
               fixed parent rather than the slab keeps this off the properties the
               expand timeline owns. */
            gsap
              .timeline({
                scrollTrigger: {
                  trigger: work,
                  start: 'top top',
                  end: '+=60%',
                  pin: work,
                  pinSpacing: true,
                  scrub: 0.4,
                  invalidateOnRefresh: true,
                },
              })
              .to(wipe, { autoAlpha: 0, scale: 1.07, duration: 0.5, ease: 'power2.inOut' }, 0.12)
              .from(work.querySelector('.work-inner'), { y: 44, autoAlpha: 0, duration: 0.45, ease: 'power3.out' }, 0.3);
    
            /* Both pins above measure the page the instant this effect runs —
               before the browser has necessarily settled on its real viewport
               (Safari's Responsive Design Mode in particular can report the
               previous window size for a frame or two after switching device
               sizes). If that first measurement is wrong, GSAP corrects itself
               on its next refresh, which lands wherever a later resize/load
               event happens to fire — often right as the intro finishes,
               reading as the card suddenly snapping into a different spot.
               Forcing one refresh after a couple of paints settles it before
               the intro is even visible instead of mid-animation. */
            requestAnimationFrame(() => requestAnimationFrame(() => ScrollTrigger.refresh()));
          }
        }, hero);
    
        return () => {
          removeMove?.();
          ctx.revert();
        };
      }, []);
    
      useEffect(() => {
        if (!ready || playedRef.current || !introRef.current) return;
        playedRef.current = true;
        introRef.current.play();
      }, [ready]);
    
      return (
        <>
          <section id="hero" ref={rootRef}>
            <div className="hero-bg">
              <img src="/images/hero-2000.jpg" alt="" fetchPriority="high" decoding="async" draggable={false} />
            </div>
            <div className="hero-wash" />
    
            {/* margin scribbles — before the scene so they sit behind the collage */}
            <div className="hero-doodles" aria-hidden="true">
              {DOODLES.map((o) => (
                <svg
                  key={o.id}
                  className={`doodle${o.wind ? ' doodle--wind' : ''}`}
                  data-drift={o.wind}
                  viewBox={o.box}
                  fill="none"
                  style={{ ...o.pos, width: o.w, rotate: `${o.rot}deg`, color: o.c }}
                >
                  {o.d.map((p, i) =>
                    o.fill ? (
                      <path key={i} d={p} fill="currentColor" />
                    ) : (
                      <path key={i} d={p} stroke="currentColor" strokeWidth={o.sw ?? 3} strokeLinecap="round" strokeLinejoin="round" />
                    ),
                  )}
                </svg>
              ))}
            </div>
    
            <div className="hero-scene">
              {/* the stamp-edged card */}
              <div className="hero-card">
                <div className="hero-paper">
                  <p className="hero-meta">
                    <strong>Full Stack Developer</strong>
                    jswnth.com
                  </p>
                  <h1 className="hero-title">
                    <span className="word">Portfolio</span>
                    <span className="year">’26</span>
                  </h1>
    
                  {/* circled in pen, the way you'd ring something on a postcard */}
                  <a className="hero-cta" href="#colophon" data-cursor="mail">
                    <span className="hero-cta-text">Say hi!</span>
                    <svg className="hero-cta-ring" viewBox="0 0 260 104" fill="none" aria-hidden="true">
                      <path
                        d="M36 58C30 26 92 8 140 10c52 2 88 20 84 44-4 26-70 42-124 40C48 92 16 76 20 50 24 26 62 12 116 12"
                        stroke="currentColor"
                        strokeWidth="3.4"
                        strokeLinecap="round"
                        pathLength={1}
                      />
                    </svg>
                    <svg className="hero-cta-spark" viewBox="0 0 40 40" aria-hidden="true">
                      <path d="M20 1c2 12 6 17 19 19-13 2-17 7-19 19-2-12-6-17-19-19 13-2 17-7 19-19Z" fill="currentColor" />
                    </svg>
                  </a>
                </div>
              </div>
    
              {/* ink postmark */}
              <div className="hero-postmark" aria-hidden="true">
                <svg viewBox="0 0 210 120" fill="none" stroke="currentColor" strokeLinecap="round">
                  <defs>
                    <path id="hero-pm-ring" d="M60,60 m-46,0 a46,46 0 1,1 92,0 a46,46 0 1,1 -92,0" />
                  </defs>
                  <circle cx="60" cy="60" r="54" strokeWidth="3" />
                  <circle cx="60" cy="60" r="38" strokeWidth="2" />
                  <text fontSize="9.2" stroke="none">
                    <textPath href="#hero-pm-ring" startOffset="0" textLength="286" lengthAdjust="spacing">
                      {POSTMARK_TEXT}
                    </textPath>
                  </text>
                  <text x="60" y="56" textAnchor="middle" fontSize="11" stroke="none">
                    {stampDate.slice(0, 6)}
                  </text>
                  <text x="60" y="72" textAnchor="middle" fontSize="14" stroke="none">
                    {stampDate.slice(7)}
                  </text>
                  <g strokeWidth="2.6">
                    <path d="M120 40q10-6 20 0t20 0t20 0t20 0" />
                    <path d="M122 54q10-6 20 0t20 0t20 0t20 0" />
                    <path d="M120 68q10-6 20 0t20 0t20 0t20 0" />
                    <path d="M122 82q10-6 20 0t20 0t20 0t20 0" />
                  </g>
                </svg>
              </div>
    
              {/* sticky note */}
              <div className="hero-drag hero-note">
                <div className="hero-float">
                  <div className="hero-note-paper">
                    <h2>Hi! I’m Jaswanth</h2>
                    <ul>
                      {ROLES.map((r) => (
                        <li key={r}>{r}</li>
                      ))}
                    </ul>
                  </div>
                </div>
              </div>
    
              {/* paperclipped polaroid */}
              <div className="hero-drag hero-polaroid">
                <div className="hero-float">
                  <figure>
                    <img src="/images/hero-2000.jpg" alt="" draggable={false} />
                    <figcaption>
                      <span>N° 01</span>
                      <span>’26</span>
                    </figcaption>
                  </figure>
                  <svg className="hero-clip" viewBox="0 0 24 60" fill="none" aria-hidden="true">
                    <path d="M6 14v32a6 6 0 0 0 12 0V10a4 4 0 0 0-8 0v34a2 2 0 0 0 4 0V14" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" />
                  </svg>
                </div>
              </div>
    
              {/* postage stamp */}
              <div className="hero-drag hero-stamp">
                <div className="hero-float">
                  <div className="hero-stamp-paper">
                    <div className="hero-stamp-inner">
                      <img src="/logos/jswnth-04-combination-mark.svg" alt="jswnth" draggable={false} />
                      <span>2026</span>
                    </div>
                  </div>
                </div>
              </div>
    
              {/* stickers */}
              {STICKERS.map((s) => (
                <div
                  key={s.label}
                  className="hero-drag hero-sticker"
                  style={{ ...s.pos, ['--c' as string]: s.c, ['--t' as string]: s.t ?? '#fff', ['--rot' as string]: `${s.rot}deg` } as React.CSSProperties}
                >
                  <div className="hero-float">{s.label}</div>
                </div>
              ))}
              <div className="hero-drag hero-sticker hero-sticker--icon hero-sticker--folder" style={{ left: '21%', top: '44%', ['--rot' as string]: '-10deg' } as React.CSSProperties}>
                <div className="hero-float">
                  <img src="/svgs/stickers.svg" alt="" draggable={false} />
                </div>
              </div>
              <div className="hero-drag hero-sticker hero-sticker--icon hero-sticker--smile" style={{ right: '22%', bottom: '28%', ['--rot' as string]: '14deg' } as React.CSSProperties}>
                <div className="hero-float">
                  <svg viewBox="0 0 64 64" aria-hidden="true">
                    <circle cx="32" cy="32" r="31" fill="#fff" />
                    <circle cx="32" cy="32" r="27" fill="var(--color-yellow)" />
                    <circle cx="23" cy="27" r="3" fill="var(--ink)" />
                    <circle cx="41" cy="27" r="3" fill="var(--ink)" />
                    <path d="M21 38c4 6 18 6 22 0" stroke="var(--ink)" strokeWidth="3" fill="none" strokeLinecap="round" />
                  </svg>
                </div>
              </div>
    
            </div>
    
            {/* the screen in between: the same postcard, no overlays on it */}
            <div className="hero-say">
              <div className="say-card">
                <div className="say-paper">
                  {/* viewBox hugs the lettering — extra headroom here reads as a
                      gap between the hello and the copy */}
                  <svg className="say-title" viewBox="0 0 640 152" role="img" aria-label="What’s Up">
                    <defs>
                      {/* a soft-edged sweep, animated across in 1.5s so the ink
                          appears to be written rather than wiped on */}
                      <linearGradient id="say-sweep" ref={sweepRef} gradientUnits="userSpaceOnUse" x1="-90" y1="0" x2="0" y2="0">
                        <stop offset="0" stopColor="#fff" />
                        <stop offset="1" stopColor="#000" />
                      </linearGradient>
                      <mask id="say-ink">
                        <rect x="0" y="0" width="640" height="152" fill="url(#say-sweep)" />
                      </mask>
                    </defs>
                    <text x="6" y="114" mask="url(#say-ink)">
                      What’s Up
                    </text>
                  </svg>
    
                  <div className="say-grid">
                    <p className="say-copy">{SAY_COPY}</p>
                    <figure className="say-photo">
                      <img src="/images/hero-2000.jpg" alt="" loading="lazy" decoding="async" draggable={false} />
                      <figcaption>
                        <span>Fig. 01</span>
                        <span>The view from here</span>
                      </figcaption>
                    </figure>
                  </div>
                </div>
              </div>
            </div>
    
            {/* nudge toward the interaction — retires itself once you've dragged */}
            <div className="hero-hint" ref={hintRef}>
              <svg className="hint-arrow" viewBox="0 0 74 58" fill="none" aria-hidden="true">
                <path d="M4 54C8 30 26 12 54 10" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
                <path d="M42 4l13 6-9 10" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
              </svg>
              <span>drag the bits around</span>
            </div>
    
            {/* spinning badge — lives outside the collage so it survives into the
                second screen and can still hand off to the wipe */}
            <a className="hero-badge" href="#work-list" aria-label="Available for work — see the work">
                <svg className="ring" viewBox="0 0 100 100" aria-hidden="true">
                  <defs>
                    <path id="hero-badge-ring" d="M50,50 m-38,0 a38,38 0 1,1 76,0 a38,38 0 1,1 -76,0" />
                  </defs>
                  <text fontSize="8.2">
                    <textPath href="#hero-badge-ring" textLength="236" lengthAdjust="spacing">
                      {RING_TEXT}
                    </textPath>
                  </text>
                </svg>
                <svg className="arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                  <path d="M12 4v16M5 13l7 7 7-7" />
                </svg>
              </a>
    
            <div className="hero-scrollhint">Scroll</div>
            <div className="hero-grain" aria-hidden="true" />
          </section>
    
          <div className="hero-wipe" ref={wipeRef} aria-hidden="true">
            <div className="hero-wipe-box" ref={wipeBoxRef} />
          </div>
    
          {/* Jaswanth's cursor — comes to collect anything left out of place */}
          <div className="hero-ghost" ref={ghostRef} aria-hidden="true">
            <svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
              <path d="M1 1L1 14.5L4.7 11.2L6.9 16.2L9 15.3L6.8 10.3L11.5 10.1L1 1Z" fill="currentColor" />
            </svg>
            <span>Jaswanth</span>
          </div>
        </>
      );
    }
    styles/portfolio/hero.css
    /* ── Hero: "Postmarked" — a stamp-edged collage laid over Bliss-green hills.
       Every piece of paper on the desk is draggable (see Hero.tsx); the desk
       tilts with the mouse; the title letters boing when brushed. ──────────── */
    
    #hero {
      position: relative;
      z-index: 1;
      min-height: 100svh;
      display: grid;
      place-items: center;
      padding: clamp(110px, 16vh, 160px) var(--pad) clamp(64px, 9vh, 96px);
      overflow: hidden;
      isolation: isolate;
      perspective: 1400px;
      color: var(--ink);
      font-family: "Caacupe One", system-ui, sans-serif;
      /* sky→grass placeholder so nothing pops while the photo streams in */
      background: linear-gradient(180deg, #5f9fdd 0%, #82b8e6 44%, #8fc25f 45%, #5c8f3a 100%);
    }
    
    /* ── backdrop ─────────────────────────────────────────────────────────── */
    .hero-bg {
      position: absolute;
      inset: -8%;
      z-index: -2;
      will-change: transform;
    }
    .hero-bg img {
      display: block;
      width: 100%;
      height: 100%;
      object-fit: cover;
      object-position: 50% 45%;
      filter: saturate(0.82) contrast(0.94) brightness(1.02);
    }
    /* washed-out, paper-warm tint like a printed photo */
    .hero-wash {
      position: absolute;
      inset: 0;
      z-index: -1;
      pointer-events: none;
      background:
        radial-gradient(120% 90% at 50% 35%, rgba(255, 242, 214, 0.22), rgba(27, 27, 57, 0) 55%),
        linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(27, 27, 57, 0.34));
    }
    
    /* ── the desk ─────────────────────────────────────────────────────────── */
    .hero-scene {
      position: relative;
      width: min(100%, 1120px);
      aspect-ratio: 16 / 10;
    }
    
    /* anything draggable: outer = drag transform, inner = idle float + tilt */
    .hero-drag {
      position: absolute;
      cursor: grab;
      user-select: none;
      -webkit-user-select: none;
      touch-action: none;
    }
    .hero-drag.is-dragging {
      cursor: grabbing;
    }
    .hero-drag img {
      -webkit-user-drag: none;
      pointer-events: none;
    }
    .hero-float {
      rotate: var(--rot, 0deg);
      will-change: transform;
    }
    
    /* ── the stamp-edged card ─────────────────────────────────────────────── */
    .hero-card {
      position: absolute;
      left: 50%;
      top: 50%;
      width: 80%;
      translate: -50% -50%;
      container-type: inline-size;
      /* shadow lives on the wrapper: a mask would clip box-shadow */
      filter: drop-shadow(0 34px 44px rgba(27, 27, 57, 0.3)) drop-shadow(0 4px 8px rgba(27, 27, 57, 0.12));
      will-change: transform;
    }
    .hero-paper {
      --s: 16px; /* perforation pitch */
      --r: 7px; /* perforation radius */
      position: relative;
      aspect-ratio: 1.55;
      background: var(--color-base-white);
      -webkit-mask:
        radial-gradient(var(--r), #0000 98%, #000) round calc(-1 * var(--s)) calc(-1 * var(--s)) / calc(2 * var(--s)) calc(2 * var(--s)),
        linear-gradient(#000 0 0) no-repeat 50% / calc(100% - 2 * var(--s)) calc(100% - 2 * var(--s));
      mask:
        radial-gradient(var(--r), #0000 98%, #000) round calc(-1 * var(--s)) calc(-1 * var(--s)) / calc(2 * var(--s)) calc(2 * var(--s)),
        linear-gradient(#000 0 0) no-repeat 50% / calc(100% - 2 * var(--s)) calc(100% - 2 * var(--s));
    }
    /* faint paper tooth */
    .hero-paper::before {
      content: "";
      position: absolute;
      inset: 0;
      opacity: 0.35;
      mix-blend-mode: multiply;
      background-image: var(--paper-noise);
      background-size: 220px;
      pointer-events: none;
    }
    
    .hero-meta {
      position: absolute;
      top: 5.5cqw;
      left: 34cqw;
      margin: 0;
      font: 700 1.25cqw / 1.6 "JetBrains Mono", ui-monospace, monospace;
      letter-spacing: 0.2em;
      text-transform: uppercase;
      color: var(--dim);
    }
    .hero-meta strong {
      display: block;
      color: var(--ink);
    }
    
    .hero-title {
      position: absolute;
      left: 5.5cqw;
      bottom: 5cqw;
      margin: 0;
      padding-top: 0.15em;
      display: flex;
      align-items: baseline;
      gap: 2cqw;
      font-weight: 400;
      line-height: 0.9;
      letter-spacing: -0.035em;
    }
    .hero-title.is-revealing {
      overflow: hidden;
    }
    .hero-title .word {
      display: inline-block;
      font-size: 19.5cqw;
      white-space: nowrap;
    }
    .hero-title .char {
      display: inline-block;
      transform-origin: 50% 90%;
      will-change: transform;
      cursor: default;
    }
    .hero-title .year {
      font: italic 700 5cqw / 1 "JetBrains Mono", ui-monospace, monospace;
      letter-spacing: -0.04em;
      color: var(--accent);
    }
    
    /* ink postmark — slammed in after the card lands */
    .hero-postmark {
      position: absolute;
      right: 15%;
      top: 13%;
      width: 17%;
      color: var(--accent);
      mix-blend-mode: multiply;
      pointer-events: none;
      rotate: -12deg;
      z-index: 2;
    }
    .hero-postmark svg {
      display: block;
      width: 100%;
      height: auto;
    }
    .hero-postmark text {
      font-family: "JetBrains Mono", ui-monospace, monospace;
      font-weight: 700;
      letter-spacing: 0.18em;
      fill: currentColor;
    }
    
    /* ── sticky note ──────────────────────────────────────────────────────── */
    .hero-note {
      left: 1.5%;
      top: 3%;
      width: clamp(170px, 27%, 320px);
      --rot: -5deg;
      z-index: 3;
    }
    .hero-note .hero-float {
      filter: drop-shadow(0 14px 18px rgba(27, 27, 57, 0.24));
    }
    .hero-note-paper {
      padding: 18px 16px 18px 30px;
      background: var(--color-pale-yellow) repeating-linear-gradient(transparent 0 25px, rgba(27, 27, 57, 0.14) 25px 26px);
      background-position: 0 -8px;
      /* spiral-notebook holes down the left edge */
      -webkit-mask: radial-gradient(5px at 11px 50%, #0000 97%, #000) 0 0 / 100% 22px;
      mask: radial-gradient(5px at 11px 50%, #0000 97%, #000) 0 0 / 100% 22px;
      font: 700 clamp(10px, 1.05vw, 12px) / 26px "JetBrains Mono", ui-monospace, monospace;
      letter-spacing: 0.06em;
      text-transform: uppercase;
      color: var(--ink);
    }
    .hero-note h2 {
      margin: 0 0 26px;
      font: inherit;
      text-decoration: underline;
      text-decoration-thickness: 1.5px;
      text-underline-offset: 6px;
    }
    .hero-note ul {
      list-style: none;
      margin: 0;
      padding: 0;
    }
    .hero-note li::before {
      content: "— ";
      color: var(--accent);
    }
    
    /* ── polaroid ─────────────────────────────────────────────────────────── */
    .hero-polaroid {
      right: 1.5%;
      top: -3%;
      width: clamp(120px, 20%, 230px);
      --rot: 6deg;
      z-index: 3;
    }
    .hero-polaroid .hero-float {
      filter: drop-shadow(0 16px 22px rgba(27, 27, 57, 0.28));
    }
    .hero-polaroid figure {
      margin: 0;
      padding: 8px 8px 12px;
      background: var(--color-base-white);
    }
    .hero-polaroid img {
      display: block;
      width: 100%;
      aspect-ratio: 1 / 1.05;
      object-fit: cover;
      object-position: 48% 12%;
      filter: saturate(0.9) contrast(1.05) sepia(0.08);
    }
    .hero-polaroid figcaption {
      margin-top: 10px;
      display: flex;
      justify-content: space-between;
      font: 700 10px / 1 "JetBrains Mono", ui-monospace, monospace;
      letter-spacing: 0.14em;
      text-transform: uppercase;
      color: var(--dim);
    }
    .hero-clip {
      position: absolute;
      top: -18px;
      right: 18px;
      width: 24px;
      height: 60px;
      color: #b4b8c4;
      filter: drop-shadow(0 1px 1px rgba(27, 27, 57, 0.35));
    }
    
    /* ── postage stamp ────────────────────────────────────────────────────── */
    .hero-stamp {
      right: 25%;
      top: 8%;
      width: clamp(64px, 10%, 112px);
      --rot: 8deg;
      z-index: 1;
    }
    .hero-stamp .hero-float {
      filter: drop-shadow(0 8px 14px rgba(27, 27, 57, 0.22));
    }
    .hero-stamp-paper {
      --s: 6px;
      --r: 2.6px;
      aspect-ratio: 0.8;
      padding: 9px;
      background: var(--color-base-white);
      -webkit-mask:
        radial-gradient(var(--r), #0000 98%, #000) round calc(-1 * var(--s)) calc(-1 * var(--s)) / calc(2 * var(--s)) calc(2 * var(--s)),
        linear-gradient(#000 0 0) no-repeat 50% / calc(100% - 2 * var(--s)) calc(100% - 2 * var(--s));
      mask:
        radial-gradient(var(--r), #0000 98%, #000) round calc(-1 * var(--s)) calc(-1 * var(--s)) / calc(2 * var(--s)) calc(2 * var(--s)),
        linear-gradient(#000 0 0) no-repeat 50% / calc(100% - 2 * var(--s)) calc(100% - 2 * var(--s));
    }
    .hero-stamp-inner {
      height: 100%;
      display: grid;
      grid-template-rows: 1fr auto;
      place-items: center;
      gap: 4px;
      padding: 7px 5px 5px;
      border: 1.5px solid var(--accent);
      background: var(--color-pale-blue);
    }
    .hero-stamp-inner img {
      width: 92%;
    }
    .hero-stamp-inner span {
      font: 700 8px / 1 "JetBrains Mono", ui-monospace, monospace;
      letter-spacing: 0.14em;
      color: var(--accent);
    }
    
    /* ── stickers ─────────────────────────────────────────────────────────── */
    .hero-sticker .hero-float {
      display: inline-flex;
      align-items: center;
      gap: 0.4em;
      padding: 0.5em 1.05em;
      border-radius: 999px;
      background: var(--c, var(--accent));
      color: var(--t, #fff);
      font-size: clamp(12px, 1.5vw, 18px);
      line-height: 1;
      letter-spacing: 0.01em;
      border: 3px solid #fff;
      box-shadow: 0 10px 22px rgba(27, 27, 57, 0.26), 0 0 0 1px rgba(27, 27, 57, 0.06);
      white-space: nowrap;
    }
    .hero-sticker--icon .hero-float {
      padding: 0;
      border: 0;
      background: none;
      box-shadow: none;
      filter: drop-shadow(0 10px 16px rgba(27, 27, 57, 0.28));
    }
    .hero-sticker--icon img,
    .hero-sticker--icon svg {
      display: block;
      width: 100%;
      height: auto;
    }
    .hero-sticker--folder {
      width: clamp(64px, 9%, 104px);
    }
    .hero-sticker--smile {
      width: clamp(44px, 6%, 68px);
    }
    
    /* ── margin scribbles ─────────────────────────────────────────────────── */
    .hero-doodles {
      position: absolute;
      inset: 0;
      pointer-events: none;
    }
    .doodle {
      position: absolute;
      height: auto;
      overflow: visible;
      color: #fff;
      filter: drop-shadow(0 2px 4px rgba(27, 27, 57, 0.35));
      will-change: transform;
    }
    
    /* ── "drag the bits around" ───────────────────────────────────────────── */
    .hero-hint {
      position: absolute;
      left: clamp(14px, 3vw, 42px);
      bottom: clamp(64px, 11vh, 118px);
      z-index: 5;
      display: flex;
      align-items: flex-end;
      gap: 6px;
      max-width: 210px;
      pointer-events: none;
      color: #fff;
      filter: drop-shadow(0 2px 6px rgba(27, 27, 57, 0.5));
    }
    .hint-arrow {
      width: clamp(38px, 4.4vw, 62px);
      height: auto;
      flex-shrink: 0;
      /* points up toward the pile of paper */
      rotate: 8deg;
    }
    .hero-hint span {
      font-family: "Caveat", cursive;
      font-size: clamp(19px, 2.1vw, 28px);
      line-height: 1.05;
      padding-bottom: 6px;
    }
    
    /* ── the circled hello, dead centre of the postcard ───────────────────── */
    .hero-cta {
      position: absolute;
      left: 50%;
      top: 50%;
      translate: -50% -50%;
      display: grid;
      place-items: center;
      padding: 2cqw 4cqw;
      text-decoration: none;
      color: var(--accent);
      z-index: 2;
    }
    .hero-cta-text {
      grid-area: 1 / 1;
      font-family: "Caveat", cursive;
      font-size: 6cqw;
      font-weight: 700;
      line-height: 1;
      color: var(--ink);
      transition: rotate 0.35s cubic-bezier(0.34, 1.5, 0.64, 1), color 0.3s ease;
    }
    .hero-cta-ring {
      grid-area: 1 / 1;
      width: 22cqw;
      height: auto;
      overflow: visible;
      color: var(--accent);
      stroke-dasharray: 1;
      transition: color 0.3s ease, scale 0.35s cubic-bezier(0.34, 1.5, 0.64, 1);
    }
    .hero-cta-spark {
      position: absolute;
      top: -0.6cqw;
      right: 0.4cqw;
      width: 3.2cqw;
      color: var(--color-orange);
    }
    .hero-cta:hover .hero-cta-text {
      rotate: -2.5deg;
      color: var(--accent);
    }
    .hero-cta:hover .hero-cta-ring {
      scale: 1.07;
      color: var(--color-orange);
    }
    
    /* ── the screen in between: same postcard, nothing pinned to it ───────── */
    .hero-say {
      position: absolute;
      inset: 0;
      z-index: 4;
      display: grid;
      place-items: center;
      padding: clamp(96px, 14vh, 156px) var(--pad) clamp(64px, 9vh, 96px);
      opacity: 0;
      visibility: hidden;
    }
    .say-card {
      width: min(100%, 1120px);
      container-type: inline-size;
      /* shadow on the wrapper: the perforation mask would clip a box-shadow */
      filter: drop-shadow(0 34px 44px rgba(27, 27, 57, 0.3)) drop-shadow(0 4px 8px rgba(27, 27, 57, 0.12));
    }
    .say-paper {
      --s: 16px;
      --r: 7px;
      position: relative;
      display: flex;
      flex-direction: column;
      justify-content: center;
      gap: 3.4cqw;
      aspect-ratio: 1.55;
      padding: 5cqw 5.5cqw;
      background: var(--color-base-white);
      -webkit-mask:
        radial-gradient(var(--r), #0000 98%, #000) round calc(-1 * var(--s)) calc(-1 * var(--s)) / calc(2 * var(--s)) calc(2 * var(--s)),
        linear-gradient(#000 0 0) no-repeat 50% / calc(100% - 2 * var(--s)) calc(100% - 2 * var(--s));
      mask:
        radial-gradient(var(--r), #0000 98%, #000) round calc(-1 * var(--s)) calc(-1 * var(--s)) / calc(2 * var(--s)) calc(2 * var(--s)),
        linear-gradient(#000 0 0) no-repeat 50% / calc(100% - 2 * var(--s)) calc(100% - 2 * var(--s));
    }
    .say-paper::before {
      content: "";
      position: absolute;
      inset: 0;
      opacity: 0.14;
      mix-blend-mode: multiply;
      background-image: var(--paper-noise);
      background-size: 220px;
      pointer-events: none;
    }
    
    .say-title {
      display: block;
      width: 44cqw;
      height: auto;
      overflow: visible;
    }
    .say-title text {
      font-family: "Caveat", "Segoe Script", cursive;
      font-size: 140px;
      font-weight: 700;
      fill: var(--accent);
    }
    
    .say-grid {
      display: grid;
      grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
      gap: 4cqw;
      align-items: start;
    }
    .say-copy {
      margin: 0;
      font-family: var(--mono);
      font-size: clamp(11px, 1.5cqw, 16px);
      font-weight: 400;
      line-height: 2;
      color: var(--ink);
    }
    /* each word starts dim and is brightened by scroll — see Hero.tsx */
    .say-word {
      opacity: 0.14;
      will-change: opacity;
    }
    
    .say-photo {
      margin: 0;
    }
    .say-photo img {
      display: block;
      width: 100%;
      aspect-ratio: 4 / 3;
      object-fit: cover;
      filter: saturate(0.88) contrast(1.04) sepia(0.06);
    }
    .say-photo figcaption {
      display: flex;
      justify-content: space-between;
      gap: 10px;
      margin-top: 1.4cqw;
      font-family: var(--mono);
      font-size: clamp(8px, 1cqw, 11px);
      font-weight: 500;
      letter-spacing: 0.16em;
      text-transform: uppercase;
      color: var(--dim);
    }
    
    /* ── spinning "available" badge ───────────────────────────────────────── */
    .hero-badge {
      position: absolute;
      right: clamp(20px, 4vw, 56px);
      bottom: clamp(20px, 4vh, 56px);
      width: clamp(84px, 9vw, 124px);
      aspect-ratio: 1;
      display: grid;
      place-items: center;
      border-radius: 50%;
      /* matches the header CTA — this circle is what swells into the wipe */
      background: #000;
      color: #fff;
      box-shadow: 0 16px 30px rgba(27, 27, 57, 0.34);
      text-decoration: none;
      /* above both screens: it outlives the collage and hands off to the wipe */
      z-index: 6;
      transition: scale 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
    }
    .hero-badge:hover {
      scale: 1.08;
    }
    .hero-badge .ring {
      position: absolute;
      inset: 0;
      width: 100%;
      height: 100%;
      animation: hero-spin 16s linear infinite;
    }
    .hero-badge:hover .ring {
      animation-duration: 5s;
    }
    .hero-badge .ring text {
      font-family: "JetBrains Mono", ui-monospace, monospace;
      font-weight: 700;
      fill: currentColor;
    }
    .hero-badge .arrow {
      width: 30%;
      height: auto;
      transition: translate 0.3s ease;
    }
    .hero-badge:hover .arrow {
      translate: 0 3px;
    }
    @keyframes hero-spin {
      to {
        transform: rotate(360deg);
      }
    }
    
    /* ── scroll hint ──────────────────────────────────────────────────────── */
    .hero-scrollhint {
      position: absolute;
      left: 50%;
      bottom: 22px;
      translate: -50% 0;
      display: flex;
      flex-direction: column;
      align-items: center;
      gap: 8px;
      font: 700 10px / 1 "JetBrains Mono", ui-monospace, monospace;
      letter-spacing: 0.3em;
      text-transform: uppercase;
      color: #fff;
      text-shadow: 0 1px 10px rgba(27, 27, 57, 0.5);
      pointer-events: none;
    }
    .hero-scrollhint::after {
      content: "";
      width: 1px;
      height: 28px;
      background: linear-gradient(#fff, transparent);
    }
    
    /* ── scroll handoff ───────────────────────────────────────────────────────
       The badge swells into a 16:9 slab that swallows the screen, then dissolves
       onto Work. Lives OUTSIDE #hero on purpose: #hero sets `perspective`, which
       would make it the containing block for a fixed child and let its
       `overflow: hidden` clip the slab. */
    .hero-wipe {
      position: fixed;
      inset: 0;
      z-index: 2000;
      overflow: hidden;
      pointer-events: none;
    }
    .hero-wipe-box {
      position: absolute;
      left: 50%;
      top: 50%;
      translate: -50% -50%;
      background: #000;
      opacity: 0;
      visibility: hidden;
      will-change: transform, opacity;
    }
    
    /* ── Jaswanth's cursor: the one that tidies up after you ──────────────── */
    .hero-ghost {
      position: fixed;
      top: 0;
      left: 0;
      /* just under the real cursor, above everything else */
      z-index: 9998;
      display: flex;
      align-items: flex-start;
      pointer-events: none;
      visibility: hidden;
      opacity: 0;
      will-change: transform;
      color: var(--color-orange);
    }
    .hero-ghost svg {
      flex-shrink: 0;
      filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.25));
      transition: scale 0.18s ease, rotate 0.18s ease;
    }
    .hero-ghost.is-grabbing svg {
      scale: 0.82;
      rotate: -12deg;
    }
    .hero-ghost span {
      margin-left: 6px;
      margin-top: 8px;
      padding: 4px 10px;
      border-radius: 14px;
      background: var(--color-orange);
      color: #fff;
      font: 600 12px / 1 system-ui, -apple-system, "Segoe UI", sans-serif;
      white-space: nowrap;
      box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
    }
    
    /* ── film grain ───────────────────────────────────────────────────────── */
    .hero-grain {
      position: absolute;
      inset: -120px;
      z-index: 5;
      pointer-events: none;
      opacity: 0.09;
      mix-blend-mode: multiply;
      background-image: var(--paper-noise);
      background-size: 260px;
      animation: hero-grain 1.1s steps(6) infinite;
    }
    @keyframes hero-grain {
      0% { translate: 0 0; }
      20% { translate: -30px 20px; }
      40% { translate: 20px -40px; }
      60% { translate: -40px -10px; }
      80% { translate: 30px 30px; }
      100% { translate: 0 0; }
    }
    
    
    /* ── small screens ────────────────────────────────────────────────────── */
    @media (max-width: 720px) {
      #hero {
        padding-top: 100px;
        padding-bottom: 72px;
      }
      .hero-scene {
        aspect-ratio: 3 / 4.3;
      }
      .hero-card {
        width: 96%;
      }
      .hero-paper {
        aspect-ratio: 1 / 1.15;
      }
      .hero-title .word {
        font-size: 24cqw;
      }
      .hero-title .year {
        font-size: 7cqw;
      }
      .hero-meta {
        top: 30cqw;
        left: 7cqw;
        font-size: 2.6cqw;
      }
      .hero-note {
        width: 50%;
        left: -3%;
        top: -3%;
        --rot: -3deg;
      }
      /* the role list is written for a wide desktop column — at this width six
         items wrap onto enough lines to drape the note over the card's own
         title underneath it, so only the first three show here */
      .hero-note li:nth-child(n + 4) {
        display: none;
      }
      .hero-note-paper {
        padding: 12px 12px 12px 22px;
        font-size: 9px;
        line-height: 19px;
      }
      .hero-note h2 {
        margin-bottom: 19px;
      }
      .hero-polaroid {
        width: 34%;
        right: -3%;
        top: -4%;
      }
      .hero-stamp {
        width: 20%;
        right: 12%;
        top: 30%;
      }
      .hero-postmark {
        width: 34%;
        right: 4%;
        top: 36%;
      }
      .hero-badge {
        width: 74px;
        right: 14px;
        bottom: 16px;
      }
    
      /* no side margins left to scribble in at this width */
      .hero-doodles,
      .hero-hint {
        display: none;
      }
      .hero-cta-text {
        font-size: 9cqw;
      }
      .hero-cta-ring {
        width: 34cqw;
      }
      .hero-cta-spark {
        width: 5cqw;
      }
    
      .hero-say {
        padding: 84px 16px 72px;
      }
      .say-paper {
        --s: 12px;
        --r: 5px;
        aspect-ratio: 1 / 1.34;
        gap: 6cqw;
        padding: 9cqw 8cqw;
      }
      .say-title {
        width: 72cqw;
      }
      .say-grid {
        grid-template-columns: 1fr;
        gap: 6cqw;
      }
      .say-copy {
        font-size: clamp(10px, 3.4cqw, 14px);
        line-height: 1.85;
      }
      .say-photo figcaption {
        font-size: 8px;
        margin-top: 3cqw;
      }
      .hero-sticker--folder {
        width: 18%;
      }
      .hero-sticker--smile {
        width: 12%;
      }
    }
    
    @media (prefers-reduced-motion: reduce) {
      .hero-grain,
      .hero-badge .ring {
        animation: none;
      }
    }
    
    /* Reduced motion never runs the pinned act-2 timeline, so the postcard panel
       can't stay stacked on top of the collage waiting to be faded in — it drops
       into flow beneath it as a second static screen. */
    /* the hero keeps its overflow:hidden — the panel is in flow, so the grid grows
       to fit it, and clipping still contains the collage pieces that sit outside
       the scene's box */
    #hero.is-static .hero-say {
      position: relative;
      inset: auto;
      padding: clamp(48px, 8vh, 88px) 0 0;
      /* in flow it becomes a grid item under #hero's place-items:center, which
         shrink-to-fits it — and the card inside sizes off a percentage, so an
         indefinite width collapses it to nothing. Match the collage's track. */
      width: min(100%, 1120px);
    }
    styles/portfolio/tokens.css
    /* Design tokens from the jswnth.com portfolio. Global element rules from
       its theme.css are deliberately left out so nothing leaks into a host page. */
    :root {
      /* primary palette */
      --color-navy-blue: #1b1b39;
      --color-purposium-blue: #3965fa;
      --color-soft-blue: #99b7fc;
      --color-pale-blue: #e9ebff;
      --color-base-white: #ffffff;
      --color-light-gray: #ededed;
      --color-dark-gray: #202020;
    
      /* secondary palette */
      --color-deep-teal: #215452;
      --color-green: #8cc63e;
      --color-pale-green: #e0ffb7;
      --color-yellow: #ffd16b;
      --color-pale-yellow: #fff2d6;
      --color-orange: #ff6d47;
      --color-brick: #6f3432;
    
      --bg: #f4f4f6;
      --ink: var(--color-navy-blue);
      --dim: #6b6b76;
      --accent: var(--color-purposium-blue);
      /* buttons + the hero's scroll wipe share this one */
      --cta: #000000;
      --pad: clamp(20px, 4vw, 56px);
    
      --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
      --display: "Caacupe One", system-ui, sans-serif;
    
      /* printed-paper tooth, reused by every sheet on the page */
      --paper-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='260' height='260'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
    }

Props

PropTypeDefaultDescription
readybooleanHolds the entrance until the page — usually a loader — says it is done.