← back_to_projects

$ cat ~/projects/my-web-portfolio.md

My portfolio website

The website you're looking at: résumé, projects and blog with a retro hacker look, built with Astro and GSAP, multilingual and generated from Markdown.

Oct 2026

  • Astro
  • GSAP
  • Tailwind CSS

The website you’re looking at

This project is, quite literally, the website you’re looking at.

For a long time I’d had the idea of building my own site to bring together my résumé, the projects I’ve worked on and a blog to share ideas, experiences and more personal things. A sort of Pandora’s box where I can keep a bit of everything.

Technology

The website is built with Astro and uses GSAP for all the animations you see. The styles are written with Tailwind CSS.

It’s set up so that I only need to write Markdown files in each of the available languages. When building, Astro automatically generates all the HTML, CSS and JavaScript for the site. The result is a pure static website, with no application server or database behind it, which is why it’s lightning fast.

Retro hacker aesthetic

I wanted the website to feel more personal, so I put together an aesthetic I call “retro hacker”. It’s based on how movies from the 70s and 80s imagined the future: years like today, with flying cars and computers talking through green screens. My idea was to bring those depictions to a website that can be viewed today on any device, something unthinkable back then.

You can see that idea in the details:

  • Green terminal: green phosphor palette, square corners and the VT323 typeface, which mimics the screens of old terminals.
  • Commands: every section is preceded by its command ($ whoami, ls ./projects, cat ./README.md…), and the cards are windows with their path in the title bar.
  • CRT image: the photo on the home page appears as if on an old TV, with scanlines and the occasional flicker.
  • Dot background: a grid that reacts as the mouse moves over it.

Animations

All the animations are declared with attributes in the HTML, without writing JavaScript for each component, and GSAP takes care of the rest:

  • Typed text: commands are typed letter by letter, with their blinking cursor.
  • Encrypted text: titles and paragraphs decode from random characters, without shifting the rest of the page.
  • Windows: cards open like the computers in 80s movies.
  • Command output: the content of each entry is printed line by line, as if it were a program’s output.

If your system has reduced motion enabled, nothing is animated.

SEO and languages

The website is available in English, as the main language, and in Spanish. When you arrive, it detects your browser’s language and takes you to the matching version, although you can always switch from the header.

Each language has its own URLs, and the site includes everything search engines need to understand them: hreflang, canonical URLs, a sitemap with the translations and metadata for sharing links on social media.

Blog

The blog has a search that runs right in the browser, with no dependencies, tag filters and searches you can link to. To publish a new post, I just write the Markdown and rebuild.

Take the animations with you

The text, window and content animations are public: copy them and use them in your own projects. They are three independent classes that work with data-* attributes on any HTML, no framework required.

Installation

All they need is GSAP, which is free with all its plugins:

npm install gsap

Copy the files in this section into a folder (for example motion/) and import main.js from your page. It works with any bundler that understands npm imports, such as Vite.

main.js

It registers the GSAP plugins and starts the three animations once the fonts have loaded, so lines are measured correctly:

import gsap from "gsap";
import { SplitText } from "gsap/SplitText";
import { ScrambleTextPlugin } from "gsap/ScrambleTextPlugin";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import MotionText from "./motion-text.js";
import MotionCard from "./motion-card.js";
import MotionProse from "./motion-prose.js";
import "./motion.css";

gsap.registerPlugin(SplitText, ScrambleTextPlugin, ScrollTrigger);

// Wait for the fonts so SplitText measures the lines correctly.
document.fonts.ready.then(() => {
  for (const Motion of [MotionText, MotionCard, MotionProse]) {
    const motion = new Motion();
    motion.init();
    motion.animationIn();
  }
});

motion.css

Animated elements start hidden until the script prepares them. The color of the flashes and the outline is set with the --motion-accent variable:

/* Hidden until the scripts prepare them (they make them visible). */
[data-motion-text],
[data-motion-card],
[data-motion-prose] {
  visibility: hidden;
}

@media (prefers-reduced-motion: reduce) {
  [data-motion-text],
  [data-motion-card],
  [data-motion-prose] {
    visibility: visible;
  }
}

/* scramble and decode: transparent copy of the final text that reserves its space
   and, on top, the animated layer. */
.motion-text-layered {
  position: relative;
}

.motion-text-ghost {
  color: transparent;
}

.motion-text-live {
  position: absolute;
  inset: 0;
  overflow-wrap: anywhere;
  pointer-events: none;
}

/* type: block cursor. */
.type-cursor {
  display: inline-block;
  width: 0.55em;
  height: 1em;
  margin-left: 0.05em;
  vertical-align: -0.1em;
  background-color: currentColor;
  animation: cursor-blink 1s steps(1) infinite;
}

@keyframes cursor-blink {
  50% {
    opacity: 0;
  }
}

/* Accent color of flashes and strokes: define `--motion-accent` in your theme. */

/* Cards: the flash layers cover their parent, so it has to be `position: relative`. */
[data-motion-card],
[data-motion-card-row] {
  position: relative;
}

[data-motion-card-flash],
[data-motion-card-row-flash] {
  position: absolute;
  inset: 0;
  z-index: 1;
  background: var(--motion-accent, #22c55e);
  opacity: 0;
  pointer-events: none;
}

/* Outline: the four sides of the stroke, in this order in the HTML: top, right, bottom, left. */
[data-motion-card-edge] {
  position: absolute;
  background: var(--motion-accent, #22c55e);
  pointer-events: none;
}
[data-motion-card-edge="top"] {
  inset: -1px -1px auto;
  height: 1px;
}
[data-motion-card-edge="right"] {
  inset: -1px -1px -1px auto;
  width: 1px;
}
[data-motion-card-edge="bottom"] {
  inset: auto -1px -1px;
  height: 1px;
}
[data-motion-card-edge="left"] {
  inset: -1px auto -1px -1px;
  width: 1px;
}

Examples

One example of each animation. Every option is documented at the top of each file.

<!-- Text -->
<p data-motion-text="type" data-motion-text-trigger="load">$ whoami</p>
<h1
  data-motion-text="scramble"
  data-motion-text-trigger="load"
  data-motion-text-delay="0.6"
>
  Hello, world
</h1>
<p
  data-motion-text="decode"
  data-motion-text-trigger="load"
  data-motion-text-delay="1.4"
  data-motion-text-speed="0.01"
>
  This paragraph is typed scrambled and then decoded from left to right.
</p>
<p data-motion-text="reveal" data-motion-text-split="lines">
  This text rises line by line from a mask when it enters the screen.
</p>
<p
  data-motion-text="type"
  data-motion-text-cursor="keep"
  data-motion-text-rotate='[{"text":"Developer","weight":3},{"text":"Gamer","weight":1}]'
>
  &gt; <span data-motion-text-rotate-target>Engineer</span>
</p>
<a href="#" data-scramble-hover>hover_me</a>

<!-- Card: window -->
<article data-motion-card>
  <span data-motion-card-flash></span>
  <header data-motion-card-content>~/projects/my-project</header>
  <div data-motion-card-content>
    <p data-motion-text="decode" data-motion-text-speed="0.01">
      Text inside a card waits for it to open.
    </p>
  </div>
</article>

<!-- Card: list (window + rows) -->
<div data-motion-card="list">
  <span data-motion-card-flash></span>
  <ul>
    <li data-motion-card-row>
      <span data-motion-card-row-flash></span>
      <span data-motion-card-row-content>first row</span>
    </li>
    <li data-motion-card-row>
      <span data-motion-card-row-flash></span>
      <span data-motion-card-row-content>second row</span>
    </li>
  </ul>
</div>

<!-- Card: outline -->
<label data-motion-card="outline" style="display: block; border: 1px solid">
  <span data-motion-card-edge="top"></span>
  <span data-motion-card-edge="right"></span>
  <span data-motion-card-edge="bottom"></span>
  <span data-motion-card-edge="left"></span>
  <input type="search" placeholder="search..." data-motion-card-content />
</label>

<!-- Card: timeline -->
<ol style="list-style: none">
  <li data-motion-card="timeline" style="padding-left: 2rem">
    <span
      data-motion-card-line
      style="position: absolute; inset: 0 auto 0 0; width: 1px; background: currentColor"
    ></span>
    <span
      data-motion-card-node
      style="position: absolute; left: -6px; top: 0.5rem; width: 13px; height: 13px; border: 1px solid"
    ></span>
    <span data-motion-card-flash></span>
    <h3 data-motion-card-content>Role @ Company</h3>
    <p data-motion-card-content>2024 — present</p>
  </li>
</ol>

<!-- Prose -->
<div data-motion-prose>
  <h2>A heading</h2>
  <p>Each direct child is printed line by line, like a command's output.</p>
  <pre><code>const hello = "world";</code></pre>
</div>
  • Text (data-motion-text): type, scramble, decode and reveal. scramble and decode are for plain text only.
  • Windows (data-motion-card): window, list, rows, outline and timeline. Animated text inside a card waits for it to open.
  • Content (data-motion-prose): each direct child of the container is printed line by line.
  • If the system has reduced motion enabled, everything is shown without animation.

Code

motion-text.js
import gsap from "gsap";
import { SplitText } from "gsap/SplitText";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { OPEN_EVENT as CARD_OPEN_EVENT } from "./motion-card.js";

/**
 * Declarative text animations with `data-*` attributes.
 *
 *   data-motion-text="reveal | type | scramble | decode"   ("true" = "reveal")
 *   data-motion-text-split="lines | words | chars"  (reveal only, default lines)
 *   data-motion-text-duration="0.6"
 *   data-motion-text-stagger="0.05"
 *   data-motion-text-delay="0"
 *   data-motion-text-speed="0.04"                   (type and decode: seconds per character)
 *   data-motion-text-cursor="keep | none"           (type: keep leaves the cursor blinking; none types without it)
 *   data-motion-text-rotate='[{"text","weight"}]'   (type: when done, deletes and types other phrases
 *                                                    in a loop inside [data-motion-text-rotate-target])
 *   data-motion-text-trigger="load | scroll"        (default scroll)
 *
 * Any element with `data-scramble-hover` scrambles its text on hover/focus
 * (only the `[data-scramble-target]` inside it, if there is one).
 *
 * Requires the SplitText, ScrambleTextPlugin and ScrollTrigger plugins to be registered.
 */

const SCRAMBLE_CHARS = "!<>-_\\/[]{}=+*^?#01ABCDEFXYZ";
// decode: seconds per character when typing the scrambled text before decoding it.
const DECODE_TYPE_SPEED = 0.004;
// decode: pause (seconds) with the scrambled text still before decoding starts.
const DECODE_PAUSE = 0.15;
// rotate: seconds each phrase stays, seconds per character when deleting
// and pause (seconds) with the empty line before typing the next one.
const ROTATE_HOLD = 3;
const ROTATE_DELETE_SPEED = 0.03;
const ROTATE_PAUSE = 0.4;
// scramble and decode: how often (seconds) the scrambled characters change.
const SCRAMBLE_REFRESH = 0.08;

const graphemes = (text) =>
  Array.from(new Intl.Segmenter().segment(text), ({ segment }) => segment);

/** Picks a random phrase by `weight`, never repeating the current one. */
const pickWeighted = (items, current) => {
  const pool = items.filter((item) => item.text !== current);
  let roll = Math.random() * pool.reduce((sum, item) => sum + item.weight, 0);
  return pool.find((item) => (roll -= item.weight) < 0) ?? pool.at(-1);
};

const randomChar = () =>
  SCRAMBLE_CHARS[Math.floor(Math.random() * SCRAMBLE_CHARS.length)];

/**
 * Returns `render(time, revealed, length)`: the first `revealed` graphemes of the final text
 * and the rest scrambled up to `length`. Spaces stay in place, so the scrambled text breaks
 * lines at the same points as the final one.
 */
const createScrambler = (text) => {
  const chars = graphemes(text);
  let noise = [];
  let lastRefresh = -Infinity;

  return (time, revealed, length = chars.length) => {
    if (time - lastRefresh >= SCRAMBLE_REFRESH) {
      noise = chars.map((char) => (/\s/.test(char) ? char : randomChar()));
      lastRefresh = time;
    }
    return chars
      .slice(0, length)
      .map((char, i) => (i < revealed ? char : noise[i]))
      .join("");
  };
};

/**
 * Reserves the space of the final text: a transparent copy takes its place (and is what
 * screen readers read) and the animation is painted on top, without layout shifts.
 * Returns the animated layer.
 */
const createTextLayers = (element, text) => {
  const ghost = document.createElement("span");
  ghost.className = "motion-text-ghost";
  ghost.textContent = text;

  const live = document.createElement("span");
  live.className = "motion-text-live";
  live.setAttribute("aria-hidden", "true");

  element.classList.add("motion-text-layered");
  element.replaceChildren(ghost, live);
  return live;
};

/** Removes the layers and leaves the final text as is. */
const removeTextLayers = (element, text) => {
  element.classList.remove("motion-text-layered");
  element.textContent = text;
};

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

class MotionText {
  elements = [];
  items = [];
  hoverCleanups = [];

  init(container = document) {
    this.elements = container.querySelectorAll("[data-motion-text]");

    if (reduceMotion()) {
      this.elements.forEach((el) => gsap.set(el, { visibility: "visible" }));
      return;
    }

    this.elements.forEach((element) => {
      const mode = this.getMode(element);
      const options = {
        duration: parseFloat(element.dataset.motionTextDuration) || 0.6,
        stagger: parseFloat(element.dataset.motionTextStagger) || 0.05,
        delay: parseFloat(element.dataset.motionTextDelay) || 0,
        speed: parseFloat(element.dataset.motionTextSpeed) || 0.04,
        trigger: element.dataset.motionTextTrigger || "scroll",
      };

      const builder = {
        reveal: this.buildReveal,
        type: this.buildType,
        scramble: this.buildScramble,
        decode: this.buildDecode,
      }[mode];

      const item = builder.call(this, element, options);
      gsap.set(element, { visibility: "visible" });
      this.items.push({ el: element, ...item, options });
    });

    this.initHoverScramble(container);
  }

  getMode(element) {
    const value = element.dataset.motionText;
    return ["type", "scramble", "decode"].includes(value) ? value : "reveal";
  }

  /** Text rising from a mask (lines, words or characters). */
  buildReveal(element, { duration, stagger, delay }) {
    const splitType = element.dataset.motionTextSplit || "lines";
    const split = new SplitText(element, {
      type: splitType,
      mask: splitType,
      [`${splitType}Class`]: splitType,
    });

    gsap.set(split[splitType], { yPercent: 120 });

    const tween = gsap.to(split[splitType], {
      yPercent: 0,
      duration,
      stagger,
      delay,
      ease: "power2.inOut",
      paused: true,
    });

    return { split, tween };
  }

  /** Typewriter: each letter appears with a cursor moving forward. */
  buildType(element, { speed, delay }) {
    const split = new SplitText(element, {
      type: "words,chars",
      charsClass: "chars",
    });

    const cursorMode = element.dataset.motionTextCursor;
    const keepCursor = cursorMode === "keep";
    let cursor = null;

    if (cursorMode !== "none") {
      cursor = document.createElement("span");
      cursor.className = "type-cursor";
      cursor.setAttribute("aria-hidden", "true");
    }

    gsap.set(split.chars, { opacity: 0 });
    // Link underlines are painted by the <a>, not the letters: hide them until typing ends.
    const links = element.querySelectorAll("a");
    if (links.length) gsap.set(links, { textDecorationColor: "transparent" });
    // Before starting, the cursor waits at the beginning of the text.
    if (cursor) split.chars[0]?.before(cursor);

    const rotate = element.dataset.motionTextRotate
      ? { items: JSON.parse(element.dataset.motionTextRotate), tween: null }
      : null;

    const tween = gsap.timeline({ paused: true, delay }).to(split.chars, {
      opacity: 1,
      duration: 0.001,
      ease: "none",
      stagger: {
        each: speed,
        onStart() {
          if (cursor) this.targets()[0].after(cursor);
        },
      },
      onComplete: () => {
        if (links.length)
          gsap.set(links, { clearProps: "textDecorationColor" });
        if (rotate) this.startRotate(element, cursor, rotate, speed);
        else if (!keepCursor) {
          // Undo the split when done: inline-block letters don't inherit,
          // for example, a link's underline.
          gsap.delayedCall(cursor ? 0.8 : 0, () => {
            cursor?.remove();
            split.revert();
          });
        }
      },
    });

    return { split, tween, cursor, rotate };
  }

  /** Loop that deletes and types another random phrase (see `data-motion-text-rotate`). */
  startRotate(element, cursor, rotate, speed) {
    const target = element.querySelector("[data-motion-text-rotate-target]");
    if (!target || !rotate.items.length) return;

    // Drop the SplitText letters so the text can be rewritten freely.
    target.textContent = target.textContent.trim();
    if (cursor) target.after(cursor);

    const write = (chars, count) => {
      target.textContent = chars.slice(0, Math.round(count)).join("");
    };

    const cycle = (current) => {
      const next = pickWeighted(rotate.items, current)?.text;
      if (!next) return;
      const from = graphemes(current);
      const to = graphemes(next);
      const state = { count: from.length };

      rotate.tween = gsap
        .timeline({ delay: ROTATE_HOLD, onComplete: () => cycle(next) })
        .to(state, {
          count: 0,
          duration: from.length * ROTATE_DELETE_SPEED,
          ease: "none",
          onUpdate: () => write(from, state.count),
        })
        .to(
          state,
          {
            count: to.length,
            duration: to.length * speed,
            ease: "none",
            onUpdate: () => write(to, state.count),
          },
          `+=${ROTATE_PAUSE}`,
        );
    };

    cycle(target.textContent);
  }

  /** Random characters that resolve into the final text. Plain text only. */
  buildScramble(element, { duration, delay }) {
    const text = element.textContent.trim();
    const live = createTextLayers(element, text);
    const render = createScrambler(text);
    const length = graphemes(text).length;
    const total = Math.max(duration, text.length * 0.035);
    // A few moments fully scrambled before decoding starts.
    const revealDelay = 0.3;
    const state = { time: 0 };

    const tween = gsap.to(state, {
      time: total,
      duration: total,
      delay,
      ease: "none",
      paused: true,
      onUpdate: () => {
        const ratio = Math.max(
          0,
          (state.time - revealDelay) / (total - revealDelay),
        );
        // The scrambled text grows fast at first and slows down at the end.
        const visible = Math.round(length * (1 - (1 - ratio) ** 3));
        live.textContent = render(
          state.time,
          Math.round(ratio * length),
          visible,
        );
      },
      onComplete: () => removeTextLayers(element, text),
    });

    return { tween, text };
  }

  /** The text is typed scrambled very fast and then decoded left to right. Plain text only. */
  buildDecode(element, { speed, delay }) {
    const text = element.textContent.trim();
    const live = createTextLayers(element, text);
    const render = createScrambler(text);
    const length = graphemes(text).length;
    const revealDelay = 0.2;
    const decodeDuration = revealDelay + text.length * speed;
    const typed = { length: 0 };
    const decoded = { time: 0 };

    const tween = gsap
      .timeline({
        delay,
        paused: true,
        onComplete: () => removeTextLayers(element, text),
      })
      // With time = 0 the scrambled characters don't change while typing.
      .to(typed, {
        length,
        duration: text.length * DECODE_TYPE_SPEED,
        ease: "none",
        onUpdate: () => {
          live.textContent = render(0, 0, Math.round(typed.length));
        },
      })
      .to(
        decoded,
        {
          time: decodeDuration,
          duration: decodeDuration,
          ease: "none",
          onUpdate: () => {
            const ratio = Math.max(
              0,
              (decoded.time - revealDelay) / (decodeDuration - revealDelay),
            );
            live.textContent = render(decoded.time, Math.round(ratio * length));
          },
        },
        `+=${DECODE_PAUSE}`,
      );

    return { tween, text };
  }

  initHoverScramble(container) {
    container.querySelectorAll("[data-scramble-hover]").forEach((el) => {
      const target = el.querySelector("[data-scramble-target]") || el;
      const text = target.textContent;
      let tween;

      const onEnter = () => {
        if (tween?.isActive()) return;
        tween = gsap.to(target, {
          duration: 0.5,
          ease: "none",
          scrambleText: { text, chars: SCRAMBLE_CHARS, speed: 0.8 },
        });
      };

      el.addEventListener("mouseenter", onEnter);
      el.addEventListener("focus", onEnter);
      this.hoverCleanups.push(() => {
        el.removeEventListener("mouseenter", onEnter);
        el.removeEventListener("focus", onEnter);
        tween?.kill();
        target.textContent = text;
      });
    });
  }

  animationIn() {
    this.items.forEach((item) => {
      // Inside an animated card, the text waits for the card to open.
      const card = item.el.closest("[data-motion-card]");
      if (card) {
        if (card.dataset.motionCardOpen !== undefined) {
          item.tween.play();
          return;
        }
        item.onCardOpen = () => item.tween.play();
        item.card = card;
        card.addEventListener(CARD_OPEN_EVENT, item.onCardOpen, { once: true });
        return;
      }

      if (item.options.trigger === "load") {
        item.tween.play();
        return;
      }

      item.scrollTrigger = ScrollTrigger.create({
        trigger: item.el,
        start: "top 90%",
        once: true,
        onEnter: () => item.tween.play(),
      });
    });
  }

  destroy() {
    this.items.forEach(
      ({
        el,
        split,
        tween,
        scrollTrigger,
        cursor,
        text,
        card,
        onCardOpen,
        rotate,
      }) => {
        scrollTrigger?.kill();
        rotate?.tween?.kill();
        card?.removeEventListener(CARD_OPEN_EVENT, onCardOpen);
        tween.kill();
        cursor?.remove();
        split?.revert();
        if (text !== undefined) removeTextLayers(el, text);
        el.dataset.motionText = false;
      },
    );

    this.hoverCleanups.forEach((cleanup) => cleanup());

    this.items = [];
    this.elements = [];
    this.hoverCleanups = [];
  }
}

export default MotionText;
motion-card.js
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";

/**
 * "80s movie computer" window opening: a dot in the center stretches into a
 * horizontal line, the line opens vertically to its final size, and the content
 * appears flickering.
 *
 *   data-motion-card                     card container. Variants:
 *                                          ""         window (default)
 *                                          "list"     window + rows printed one by one
 *                                          "rows"     rows only, no window
 *                                          "outline"  the border is drawn clockwise
 *                                          "timeline" timeline entry: line, node and scan line
 *   data-motion-card-flash               color layer that flashes when opening
 *   data-motion-card-content             parts that appear at the end
 *   data-motion-card-delay="0"           seconds
 *   data-motion-card-trigger="load | scroll | parent"   (default scroll; parent waits for the
 *                                        card that contains it to open, or scroll if there is none)
 *
 * Cards entering the screen together open staggered (`STAGGER`).
 * Cards can be nested: each card only animates its own parts, not those of an inner card.
 * When a card finishes opening it gets `data-motion-card-open` and dispatches `OPEN_EVENT`.
 */

const STAGGER = 0.12;
const OPEN_EVENT = "motioncard:open";

/** Elements matching `selector` that belong to card `el` (and not to a nested card). */
const own = (el, selector) =>
  [...el.querySelectorAll(selector)].filter(
    (node) => node.closest("[data-motion-card]") === el,
  );

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

class MotionCard {
  items = [];
  batches = [];

  init(container = document) {
    const elements = container.querySelectorAll("[data-motion-card]");

    if (reduceMotion()) {
      elements.forEach((el) => gsap.set(el, { visibility: "visible" }));
      return;
    }

    elements.forEach((el) => {
      const tween = this.buildOpen(el);
      gsap.set(el, { visibility: "visible" });
      this.items.push({
        el,
        tween,
        delay: parseFloat(el.dataset.motionCardDelay) || 0,
        trigger: el.dataset.motionCardTrigger || "scroll",
      });
    });
  }

  buildOpen(el) {
    const timeline = gsap.timeline({
      paused: true,
      onComplete: () => {
        gsap.set(el, { clearProps: "transform" });
        // Tells the text animations waiting inside the card.
        el.dataset.motionCardOpen = "";
        el.dispatchEvent(new CustomEvent(OPEN_EVENT));
      },
    });

    const variant = el.dataset.motionCard;

    if (variant === "rows") {
      this.prepareFlash(el);
      // With no window around them, each row is uncovered along with its scan bar.
      this.addRows(el, timeline, { reveal: true });
      this.addContent(el, timeline, "-=0.1");
      return timeline;
    }

    if (variant === "outline") {
      this.buildOutline(el, timeline);
      this.addContent(el, timeline, "<0.05");
      return timeline;
    }

    const build =
      variant === "timeline" ? this.buildTimeline : this.buildWindow;
    build.call(this, el, timeline);
    this.addFlashOut(el, timeline);
    this.addContent(el, timeline, "<0.05");
    if (variant === "list") this.addRows(el, timeline);

    return timeline;
  }

  /** Window: center dot → horizontal line → opens vertically. */
  buildWindow(el, tl) {
    this.prepareFlash(el);
    gsap.set(el, { scaleX: 0, scaleY: 0, transformOrigin: "50% 50%" });

    tl
      // 1. A dot of light in the center.
      .to(el, { scaleX: 0.02, scaleY: 0.01, duration: 0.06, ease: "none" })
      // 2. It stretches into a horizontal line (in steps, like an old CRT).
      .to(el, { scaleX: 1, duration: 0.28, ease: "steps(7)" }, "+=0.06")
      // 3. The line opens vertically.
      .to(el, { scaleY: 1, duration: 0.3, ease: "steps(8)" }, "+=0.04");
  }

  /**
   * Timeline entry: the vertical line is drawn downwards, the node lights up
   * flickering and the flash comes out of it as a scan line that then opens
   * downwards over the content.
   *
   *   data-motion-card-line   vertical line
   *   data-motion-card-node   milestone node
   */
  buildTimeline(el, tl) {
    const [line] = own(el, "[data-motion-card-line]");
    const [node] = own(el, "[data-motion-card-node]");
    const flash = this.prepareFlash(el);

    gsap.set(line, { scaleY: 0, transformOrigin: "50% 0%" });
    gsap.set(node, { autoAlpha: 0 });
    gsap.set(flash, { scaleX: 0, scaleY: 0.03, transformOrigin: "0% 0%" });

    tl
      // 1. The line goes down in steps.
      .to(line, { scaleY: 1, duration: 0.35, ease: "steps(6)" })
      // 2. The node lights up flickering.
      .to(
        node,
        {
          keyframes: { autoAlpha: [0, 1, 0, 1, 0.4, 1] },
          duration: 0.2,
          ease: "none",
        },
        "<0.05",
      )
      // 3. Scan line to the right and opening downwards.
      .to(flash, { scaleX: 1, duration: 0.26, ease: "steps(7)" }, "+=0.02")
      .to(flash, { scaleY: 1, duration: 0.24, ease: "steps(6)" }, "+=0.04");
  }

  /**
   * Outline: the four sides are drawn one after another clockwise, in steps.
   * Once closed, the real background and border appear, the stroke fades out
   * flickering and the content appears flickering.
   *
   *   data-motion-card-edge   stroke sides, in this order: top, right, bottom, left
   */
  buildOutline(el, tl) {
    const edges = own(el, "[data-motion-card-edge]");
    const origins = ["0% 50%", "50% 0%", "100% 50%", "50% 100%"];

    this.prepareFlash(el);
    gsap.set(el, {
      borderColor: "transparent",
      backgroundColor: "transparent",
    });

    edges.forEach((edge, i) => {
      const horizontal = i % 2 === 0;
      gsap.set(edge, {
        [horizontal ? "scaleX" : "scaleY"]: 0,
        transformOrigin: origins[i],
      });
      tl.to(edge, {
        [horizontal ? "scaleX" : "scaleY"]: 1,
        duration: horizontal ? 0.22 : 0.1,
        ease: horizontal ? "steps(8)" : "steps(3)",
      });
    });

    tl.set(el, { clearProps: "borderColor,backgroundColor" }, "+=0.05").to(
      edges,
      {
        keyframes: { opacity: [1, 0.15, 0.7, 0.05, 0.35, 0] },
        duration: 0.3,
        ease: "none",
      },
    );
  }

  prepareFlash(el) {
    const [flash] = own(el, "[data-motion-card-flash]");
    // The "list" and "rows" variants may have no content of their own (they use rows).
    const content = own(el, "[data-motion-card-content]");
    if (content.length) gsap.set(content, { autoAlpha: 0 });
    if (flash) gsap.set(flash, { opacity: 1 });
    return flash;
  }

  /** Common ending: the flash fades out flickering... */
  addFlashOut(el, tl) {
    const [flash] = own(el, "[data-motion-card-flash]");
    if (!flash) return;

    tl.to(flash, {
      keyframes: { opacity: [1, 0.15, 0.7, 0.05, 0.35, 0] },
      duration: 0.3,
      ease: "none",
    });
  }

  /** ...and the content appears flickering. */
  addContent(el, tl, position) {
    const content = own(el, "[data-motion-card-content]");
    if (!content.length) return;

    tl.to(
      content,
      {
        keyframes: { autoAlpha: [0, 1, 0.3, 1, 0.6, 1] },
        duration: 0.35,
        ease: "none",
        stagger: 0.08,
      },
      position,
    );
  }

  /**
   * Rows ("list" and "rows"): each row is "printed" like a line of `ls`: a scan bar
   * crosses it and the text appears flickering.
   *
   *   data-motion-card-row           row
   *   data-motion-card-row-flash     row scan bar
   *   data-motion-card-row-content   row content
   *
   * With `reveal`, the row (border included) is clipped until its turn and uncovered
   * from left to right along with the bar.
   */
  addRows(el, tl, { reveal = false } = {}) {
    own(el, "[data-motion-card-row]").forEach((row, i) => {
      const flash = row.querySelector("[data-motion-card-row-flash]");
      const content = row.querySelector("[data-motion-card-row-content]");

      gsap.set(flash, { opacity: 1, scaleX: 0, transformOrigin: "0% 50%" });
      gsap.set(content, { autoAlpha: 0 });
      if (reveal) gsap.set(row, { clipPath: "inset(0 100% 0 0)" });

      const at = i === 0 ? "<0.1" : "<0.12";
      tl.to(flash, { scaleX: 1, duration: 0.18, ease: "steps(6)" }, at);
      if (reveal) {
        tl.to(
          row,
          {
            clipPath: "inset(0 0% 0 0)",
            duration: 0.18,
            ease: "steps(6)",
            onComplete: () => gsap.set(row, { clearProps: "clipPath" }),
          },
          "<",
        );
      }
      tl.to(flash, {
        keyframes: { opacity: [1, 0.2, 0.6, 0] },
        duration: 0.2,
        ease: "none",
      }).to(
        content,
        {
          keyframes: { autoAlpha: [0, 1, 0.3, 1] },
          duration: 0.25,
          ease: "none",
        },
        "<",
      );
    });
  }

  /** Starts the card after its delay (+ `extra`). `delay()` + `play()` would ignore the delay on a paused timeline. */
  play(item, extra) {
    item.tween.delay(item.delay + extra).restart(true);
  }

  animationIn() {
    this.items.forEach((item) => {
      if (item.trigger !== "parent") return;
      const parent = item.el.parentElement?.closest("[data-motion-card]");
      if (!parent) {
        item.trigger = "scroll";
        return;
      }
      if (parent.dataset.motionCardOpen !== undefined) {
        this.play(item, 0);
        return;
      }
      item.parent = parent;
      item.onParentOpen = () => this.play(item, 0);
      parent.addEventListener(OPEN_EVENT, item.onParentOpen, { once: true });
    });

    this.items
      .filter((item) => item.trigger === "load")
      .forEach((item) => this.play(item, 0));

    const scrollItems = this.items.filter((item) => item.trigger === "scroll");
    if (!scrollItems.length) return;

    this.batches = ScrollTrigger.batch(
      scrollItems.map((item) => item.el),
      {
        start: "top 90%",
        once: true,
        onEnter: (elements) => {
          elements.forEach((el, i) => {
            const item = this.items.find((it) => it.el === el);
            if (item) this.play(item, i * STAGGER);
          });
        },
      },
    );
  }

  destroy() {
    this.batches.forEach((st) => st.kill());
    this.items.forEach(({ el, tween, parent, onParentOpen }) => {
      parent?.removeEventListener(OPEN_EVENT, onParentOpen);
      tween.kill();
      gsap.set(el, { clearProps: "transform,visibility" });
      delete el.dataset.motionCardOpen;
      gsap.set(
        el.querySelectorAll(
          "[data-motion-card-flash], [data-motion-card-content], [data-motion-card-line], [data-motion-card-node], [data-motion-card-row-flash], [data-motion-card-row-content], [data-motion-card-edge], [data-motion-card-row]",
        ),
        { clearProps: "opacity,visibility,transform,clipPath" },
      );
    });

    this.items = [];
    this.batches = [];
  }
}

export { OPEN_EVENT };
export default MotionCard;
motion-prose.js
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";

/**
 * Content that gets "printed" like a command's output: each block (paragraph, heading,
 * list, code, image…) is revealed line by line from top to bottom, in steps, and blocks
 * entering the screen together are printed in order.
 *
 *   data-motion-prose               container; its direct children are animated
 *   data-motion-prose-delay="0"     seconds to wait for what is visible on load
 *
 * When reloading halfway through or entering through a #anchor, whatever is above the
 * screen is shown right away and the initial delay is skipped.
 */

// Seconds per line when printing a block.
const LINE_SPEED = 0.03;
// Maximum duration of a block, so a very long one doesn't keep the rest waiting.
const MAX_BLOCK_DURATION = 0.8;

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

/** Approximate number of lines of the block from its height and line-height. */
const countLines = (block) => {
  const style = getComputedStyle(block);
  const lineHeight =
    parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2 || 24;
  return Math.max(1, Math.round(block.offsetHeight / lineHeight));
};

class MotionProse {
  containers = [];
  batches = [];
  tweens = [];

  init(container = document) {
    this.containers = [...container.querySelectorAll("[data-motion-prose]")];

    if (reduceMotion()) {
      this.containers.forEach((el) => gsap.set(el, { visibility: "visible" }));
      return;
    }

    this.containers.forEach((el) => {
      gsap.set(el.children, { clipPath: "inset(0 0 100% 0)" });
      gsap.set(el, { visibility: "visible" });
    });
  }

  animationIn() {
    this.containers.forEach((el) => {
      let pendingDelay = parseFloat(el.dataset.motionProseDelay) || 0;

      this.batches.push(
        ...ScrollTrigger.batch(el.children, {
          start: "top 90%",
          once: true,
          onEnter: (blocks) => {
            // The initial delay only applies to what is visible on load, and only
            // if the container starts below the top edge of the screen.
            let delay = el.getBoundingClientRect().top > 0 ? pendingDelay : 0;
            pendingDelay = 0;

            blocks.forEach((block) => {
              // Whatever is already above the screen is not animated and doesn't delay the rest.
              if (block.getBoundingClientRect().bottom <= 0) {
                gsap.set(block, { clearProps: "clipPath" });
                return;
              }

              const lines = countLines(block);
              const duration = Math.min(lines * LINE_SPEED, MAX_BLOCK_DURATION);

              this.tweens.push(
                gsap.to(block, {
                  clipPath: "inset(0 0 0% 0)",
                  duration,
                  delay,
                  ease: `steps(${lines})`,
                  onComplete: () => gsap.set(block, { clearProps: "clipPath" }),
                }),
              );
              delay += duration;
            });
          },
        }),
      );
    });
  }

  destroy() {
    this.batches.forEach((st) => st.kill());
    this.tweens.forEach((tween) => tween.kill());
    this.containers.forEach((el) => {
      gsap.set(el, { clearProps: "visibility" });
      gsap.set(el.children, { clearProps: "clipPath" });
    });

    this.containers = [];
    this.batches = [];
    this.tweens = [];
  }
}

export default MotionProse;

Before you go…

There are a few easter eggs hidden around! Opening the browser console is a good place to start looking for them.

   .-.                         _____
  /~~~\                     __|_____|__
 |=====|                       (•_•)
  \~~~/                         /|\
   `-'  . . . . . . . . . . .   / \