$ cat ~/projects/my-web-portfolio.md
Mi web porfolio
La web que estás viendo: currículum, proyectos y blog con estética retro hacker, hecha con Astro y GSAP, multiidioma y generada a partir de Markdown.
oct 2026
- Astro
- GSAP
- Tailwind CSS
La web que estáis viendo
Este proyecto es, literalmente, la web que estáis viendo.
Desde hacía tiempo me rondaba la idea de tener un sitio propio que reuniera mi currículum, los proyectos que he ido haciendo y un blog donde contar ideas, experiencias y cosas más personales. Una especie de caja de Pandora donde ir guardando un poco de todo.
Tecnología
La web está desarrollada con Astro y usa GSAP para todas las animaciones que veis. Los estilos están hechos con Tailwind CSS.
Está montada de forma que solo tengo que escribir ficheros Markdown en cada uno de los idiomas disponibles. Al compilar, Astro genera automáticamente el HTML, el CSS y el JavaScript de toda la web. El resultado es una web estática pura, sin servidor de aplicación ni base de datos detrás, y por eso va rápida como un rayo.
Estética retro hacker
Quería que la web fuera algo más personal, así que monté una estética que yo llamo “retro hacker”. Está basada en cómo las películas de los 70 y 80 imaginaban el futuro: años como el actual, con coches voladores y ordenadores que hablaban en pantallas verdes. Mi idea era llevar esas representaciones a una web que hoy se puede ver desde cualquier dispositivo, algo impensable en aquella época.
Esa idea se nota en los detalles:
- Terminal verde: paleta de fósforo verde, esquinas rectas y la tipografía VT323, que imita las pantallas de los terminales antiguos.
- Comandos: cada sección va precedida de su comando (
$ whoami,ls ./projects,cat ./README.md…), y las tarjetas son ventanas con su ruta en la barra de título. - Imagen CRT: la foto del inicio aparece como en un televisor antiguo, con líneas de barrido y algún que otro parpadeo.
- Fondo de puntos: una rejilla que reacciona al paso del ratón.
Animaciones
Todas las animaciones se declaran con atributos en el HTML, sin escribir JavaScript en cada componente, y GSAP se encarga del resto:
- Texto tecleado: los comandos se escriben letra a letra, con su cursor parpadeando.
- Texto cifrado: los títulos y párrafos se descifran a partir de caracteres aleatorios, sin mover el resto de la página.
- Ventanas: las tarjetas se abren como los ordenadores de las películas de los 80.
- Salida de comandos: el contenido de las entradas se imprime línea a línea, como si fuera la salida de un programa.
Si el sistema tiene activada la opción de reducir el movimiento, no se anima nada.
SEO e idiomas
La web está disponible en inglés, como idioma principal, y en español. Al entrar, detecta el idioma del navegador y te lleva a la versión correspondiente, aunque siempre puedes cambiarlo desde la cabecera.
Cada idioma tiene sus propias URL, y la web incluye todo lo necesario para que los buscadores las entiendan: hreflang, URL canónicas, sitemap con las traducciones y metadatos para compartir enlaces en redes sociales.
Blog
El blog tiene un buscador que funciona en el propio navegador, sin dependencias, con filtros por etiquetas y búsquedas que se pueden enlazar. Para publicar una entrada nueva basta con escribir el Markdown y volver a compilar.
Llévate las animaciones
Las animaciones de texto, de las ventanas y del contenido son públicas: cópialas y úsalas en tus proyectos sin problema. Son tres clases independientes que funcionan con atributos data-* sobre cualquier HTML, sin framework.
Instalación
Solo necesitan GSAP, que es gratuito con todos sus plugins:
npm install gsap
Copia los ficheros de esta sección en una carpeta (por ejemplo motion/) e importa main.js desde tu página. Funciona con cualquier bundler que entienda los imports de npm, como Vite.
main.js
Registra los plugins de GSAP y arranca las tres animaciones cuando cargan las fuentes, para que las líneas se midan bien:
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
Los elementos animados empiezan ocultos hasta que el script los prepara. El color de los destellos y del contorno se cambia con la variable --motion-accent:
/* 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;
}
Ejemplos
Un ejemplo de cada animación. Todas las opciones están documentadas al principio de cada fichero.
<!-- 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}]'
>
> <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>
- Texto (
data-motion-text):type,scramble,decodeyreveal.scrambleydecodeson solo para texto plano. - Ventanas (
data-motion-card): ventana,list,rows,outlineytimeline. El texto animado que hay dentro de una card espera a que esta se abra. - Contenido (
data-motion-prose): cada hijo directo del contenedor se imprime línea a línea. - Si el sistema tiene activada la opción de reducir el movimiento, todo se muestra sin animar.
Código
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;
Antes de terminar…
¡Hay algún que otro easter egg escondido! Abrir la consola del navegador es un buen comienzo para encontrarlos.
.-. _____
/~~~\ __|_____|__
|=====| (•_•)
\~~~/ /|\
`-' . . . . . . . . . . . / \