Scramble / Decode Text
A mono headline and five nav-style rows decode from random glyphs (A–Z, 0–9, #@%&!) into real words on load and re-scramble on hover, while a status line decodes a new word every three seconds.
- Text
- gsap
- beginner
- light perf
- mobile: full
- reduced-motion ✓
AI prompt
Recreate this effect with any AI tool
Paste into Claude, Cursor, v0, Lovable or Bolt. 1254 words of exact values — rewritten live as you tune the Playground.
### EFFECT: Scramble / Decode Text
**What it does (visual):** A dark, monospace page. Top-left a small eyebrow: a glowing `#d4ff3a` dot, a status word ("LISTENING") and "— ch. 04". Below it a large, light monospace headline, then a list of five nav rows separated by hairlines — each row has an index ("01"), a big label ("Index", "Studio", "Archive", "Journal", "Contact") and a muted mono meta ("142 entries"). On load the headline and the five labels are *not* readable: they are strings of random glyphs from the `alphanumeric` set (alphanumeric = `ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#@%&!`, binary = `01`, blocks = `▮▯░▒▓`, lowercase = `a–z`) of exactly the final length. 0.2s after load the headline starts "decoding": the glyphs keep churning for 0.3s, then real letters lock in from left to right (from the right when rightToLeft = false) until the full phrase "Signal from noise" is resolved 1.2s later. The five labels decode the same way, starting at 0.5s and each 0.1s after the previous, while their rows fade in. A footnote fades in at 1.6s. Afterwards: hovering (or keyboard-focusing) a row re-encodes just its label for 0.8s and draws a `#d4ff3a` hairline under the row; and every 3 seconds the status word decodes into the next one in the cycle LISTENING → DECODING → TRANSMITTING → RENDERING. The dot pulses slowly the whole time.
**Tech stack:**
- GSAP 3.13+ with ScrambleTextPlugin (free since 3.13). No ScrollTrigger needed.
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrambleTextPlugin.min.js"></script>`
- npm: `import gsap from "gsap"; import { ScrambleTextPlugin } from "gsap/ScrambleTextPlugin"; gsap.registerPlugin(ScrambleTextPlugin);`
- Font: a monospace with a light weight (demo: JetBrains Mono 300) — essential so scrambled glyphs don't change the line width.
**HTML structure:**
```html
<section class="scr">
<div class="scr__inner">
<p class="scr__eyebrow"><span class="scr__dot" aria-hidden="true"></span><span class="scr__status" data-words="LISTENING|DECODING|TRANSMITTING|RENDERING">LISTENING</span><span class="scr__channel">— ch. 04</span></p>
<h1 class="scr__title">Signal from noise</h1>
<nav class="scr__nav" aria-label="Sections">
<a class="scr__item" href="#index"><span class="scr__index">01</span><span class="scr__label">Index</span><span class="scr__meta">142 entries</span></a>
<a class="scr__item" href="#studio"><span class="scr__index">02</span><span class="scr__label">Studio</span><span class="scr__meta">Oslo · Lisbon</span></a>
<a class="scr__item" href="#archive"><span class="scr__index">03</span><span class="scr__label">Archive</span><span class="scr__meta">2014 — 2026</span></a>
<a class="scr__item" href="#journal"><span class="scr__index">04</span><span class="scr__label">Journal</span><span class="scr__meta">Weekly</span></a>
<a class="scr__item" href="#contact"><span class="scr__index">05</span><span class="scr__label">Contact</span><span class="scr__meta">Open for Q1</span></a>
</nav>
<p class="scr__footnote">Hover an item to re-encode it.</p>
</div>
</section>
```
**CSS requirements:**
- `.scr`: `min-height: 100vh; display: grid; place-items: center; padding: 8vw 6vw; background: var(--bg, #0a0a0a); color: var(--text, #ededed); font-family: var(--font-mono);`
- `.scr__inner`: `width: 100%; max-width: 1100px;`
- `.scr__eyebrow`: `display: flex; align-items: center; gap: .9em; font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted);` `.scr__status { color: var(--text); min-width: 13ch; }` (reserves the width of the longest word so the line never shifts).
- `.scr__dot`: `width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #d4ff3a); box-shadow: 0 0 12px var(--accent);`
- `.scr__title`: `font-weight: 300; font-size: clamp(2.4rem, 7vw, 6.5rem); line-height: 1; letter-spacing: -0.04em; margin: .6em 0 .7em;`
- `.scr__nav`: `display: grid; border-top: 1px solid var(--line);`
- `.scr__item`: `position: relative; display: grid; grid-template-columns: 3.5ch 1fr auto; align-items: baseline; gap: clamp(1rem, 3vw, 3rem); padding: clamp(.9rem, 1.8vw, 1.4rem) 0; border-bottom: 1px solid var(--line); text-decoration: none; color: inherit;`
- `.scr__item::after`: `content: ''; position: absolute; left: 0; bottom: -1px; height: 1px; width: 100%; background: var(--accent); transform: scaleX(0); transform-origin: left; transition: transform .5s cubic-bezier(.22, 1, .36, 1);` → `scaleX(1)` on `:hover` and `:focus-visible`.
- `.scr__index`, `.scr__meta`, `.scr__footnote`: 11px uppercase `letter-spacing: .12em; color: var(--muted);`
- `.scr__label`: `font-weight: 300; font-size: clamp(1.3rem, 3.2vw, 2.6rem); letter-spacing: -0.03em; line-height: 1;`
- Under 768px: `.scr__item { grid-template-columns: 3ch 1fr; } .scr__meta { display: none; }`
**Animation spec (exact values):**
Constants: `CHARS` = the `alphanumeric` glyph set (see above), `SPEED = 0.6`, `REVEAL_DELAY = 0.3`, `DURATION = 1.2`, `RTL = false`, `CYCLE = 3`.
1. Before anything renders: store each decode target's real copy in `el.dataset.text` and replace its `textContent` with a random string from `CHARS` of the same length (title + the five labels).
2. Load timeline `tl = gsap.timeline({ defaults: { ease: 'none' } })`:
- `tl.from('.scr__eyebrow', { opacity: 0, duration: 0.6 }, 0)`
- `tl.to(title, { duration: 1.2, scrambleText: { text: title.dataset.text, chars: CHARS, speed: 0.6, revealDelay: 0.3, tweenLength: false, rightToLeft: false } }, 0.2)` — 0.3s of noise, then letters resolve left → right, finished at 0.2 + 1.2s.
- `tl.from('.scr__item', { opacity: 0, duration: 0.5, stagger: 0.1 }, 0.5)` — rows fade in.
- For each label `i` (0–4): `tl.to(label, { duration: 1.2, scrambleText: { text: label.dataset.text, chars: CHARS, speed: 0.6, revealDelay: 0.3, tweenLength: false, rightToLeft: false } }, 0.5 + i * 0.1)`.
- `tl.from('.scr__footnote', { opacity: 0, duration: 0.6 }, 1.6)`.
3. Status cycle (words from `data-words`, split on `|`):
```js
let w = 0;
const cycle = () => { w = (w + 1) % words.length;
gsap.to(status, { duration: 1, ease: 'none', scrambleText: { text: words[w], chars: CHARS, speed: 0.6, revealDelay: 0.2, tweenLength: true, rightToLeft: false },
onComplete: () => gsap.delayedCall(3, cycle) }); };
gsap.delayedCall(3, cycle);
```
(`delayedCall` — not `setInterval` — so the cycle pauses with `gsap.globalTimeline`.)
4. Dot: `gsap.to('.scr__dot', { opacity: 0.25, duration: 0.9, yoyo: true, repeat: -1, ease: 'sine.inOut' })`.
5. Hover/focus on a row: `gsap.to(label, { duration: 0.8, ease: 'none', overwrite: 'auto', scrambleText: { text: label.dataset.text, chars: CHARS, speed: 0.6, revealDelay: 0.15, tweenLength: false, rightToLeft: false } })`.
**Interaction:** `pointerenter` and `focus` on `.scr__item` re-scramble that row's label (0.8s, resolves to the same word) and the lime hairline draws under the row over 0.5s (CSS transition). Leaving does nothing extra — the word is already resolved. Rows are real links.
**Responsive behavior:** All sizes are fluid. Under 768px the meta column is hidden and the row grid becomes `3ch 1fr`. Hover re-scramble simply doesn't occur on touch (no `pointerenter` without a hover-capable pointer); tapping a row follows the link. No `matchMedia` branches are needed for timing — the decode reads well at any size.
**Accessibility:** With `prefers-reduced-motion: reduce` do not pre-scramble or animate anything: the real headline, labels and status are shown immediately, the dot does not pulse and the underline transition is disabled. During the decode the DOM text is noise, so keep the decode short (≤ 1.5s) and keep the `<nav aria-label="Sections">` and `<h1>` semantics; links stay focusable with the underline doubling as the focus indicator (`:focus-visible`). The dot is `aria-hidden`.
**Performance rules:** ScrambleText writes `textContent` ~60×/s per active element — only 6 elements decode at once, which is trivial, but avoid scrambling whole paragraphs. All tweens, delayedCalls and the repeat live inside `gsap.context()`; `ctx.revert()` on unmount restores the original text and kills the cycle. Remove the hover/focus listeners in the same cleanup. The host's `globalTimeline` pause stops the status cycle and dot pulse off-screen.
**Customization knobs:**
- `CHARS` (`alphanumeric`) — the glyph alphabet (`binary` = `'01'` for a terminal feel, `blocks` = `'▮▯░▒▓'`, `lowercase` for softer noise).
- `SPEED` (0.6) — churn rate; `REVEAL_DELAY` (0.3) — how long it stays pure noise; `DURATION` (1.2).
- `CYCLE` (3) and `data-words` — the status vocabulary.
- `tweenLength: false` keeps widths stable; `true` lets short → long words grow.
- `rightToLeft` (false) — resolve from the right instead.
- Accent colour for the dot and hover hairline (`#d4ff3a`).
**Acceptance criteria:**
- [ ] Before 0.2s the headline and labels are unreadable noise of the correct length; no real word flashes first.
- [ ] Headline is fully resolved 1.2s after it starts (0.2s); labels resolve in a 0.1s cascade starting at 0.5s; footnote appears at 1.6s.
- [ ] Hover/focus re-scrambles only that label and draws the accent underline; rapid re-hovers don't stack.
- [ ] Status word changes every 3s via a 1s decode and the line width never shifts.
- [ ] Reduced-motion users see the final text immediately with no scrambling or pulsing.
- [ ] No layout shift (monospace, fixed lengths); no console errors. Add this effect to my existing site/section: **Scramble / Decode Text**. Keep my headline and navigation markup and set them in my monospace font. Load GSAP 3.13+ and ScrambleTextPlugin (`gsap.registerPlugin(ScrambleTextPlugin)`). For the headline and each nav label: store the real text in `data-text`, pre-fill the element with random glyphs from the `alphanumeric` set (alphanumeric = `'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#@%&!'`, binary = `'01'`, blocks = `'▮▯░▒▓'`, lowercase = `'a–z'`) of the same length, then in one timeline run `gsap.to(el, { duration: 1.2, scrambleText: { text: el.dataset.text, chars: CHARS, speed: 0.6, revealDelay: 0.3, tweenLength: false, rightToLeft: false } })` — headline at 0.2s, labels from 0.5s with a 0.1s cascade (fade their rows in alongside). On `pointerenter`/`focus` of a nav row re-scramble its label for 0.8s (`revealDelay: 0.15`, `overwrite: 'auto'`) and draw a 1px `#d4ff3a` hairline under it. If I have a status/eyebrow line, cycle it through a word list every 3s with a 1s `scrambleText` tween chained by `gsap.delayedCall`. Under `prefers-reduced-motion` skip everything and show the real text. Wrap in `gsap.context()`, revert on unmount, and don't change my colours or layout. ### EFFECT (React / Next.js): Scramble / Decode Text
Build **Scramble / Decode Text** as a self-contained React component for a Next.js App Router project (also valid in Vite + React). Follow the specification below exactly; the vanilla reference implementation at the end is the source of truth for values and structure.
**Setup**
- `npm i gsap @gsap/react`
- File: `components/ScrambleDecodeText.tsx`, first line `"use client";` (GSAP touches the DOM).
- Imports:
```tsx
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { ScrambleTextPlugin } from "gsap/ScrambleTextPlugin";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(useGSAP, ScrambleTextPlugin);
export default function ScrambleDecodeText() {
const scope = useRef<HTMLDivElement>(null);
useGSAP(() => {
// 1. query elements inside `scope.current` (never document-wide)
// 2. wait for document.fonts.ready before any SplitText
// 3. build the timeline / ScrollTriggers exactly as in the specification
// 4. return nothing — useGSAP reverts everything automatically on unmount
}, { scope });
return (
<div ref={scope} className="scr">
{/* markup from the HTML structure section, class → className */}
</div>
);
}
```
**React-specific rules**
- Everything GSAP-related lives inside `useGSAP(() => {...}, { scope })`; it handles cleanup (`ctx.revert()`) on unmount and in React Strict Mode double-invocation.
- Use refs or `gsap.utils.selector(scope)` instead of `document.querySelector`.
- Event listeners added inside useGSAP must use `contextSafe` (`const { contextSafe } = useGSAP({ scope })`) so their tweens are reverted too.
- ScrollTrigger: call `ScrollTrigger.refresh()` after images/fonts load; if the page uses Lenis, sync it once at the app root (`lenis.on('scroll', ScrollTrigger.update)`).
- Styles: copy the CSS requirements into a CSS Module (`ScrambleDecodeText.module.css`) or Tailwind; keep the class names so the spec maps 1:1.
- Respect `prefers-reduced-motion` with `gsap.matchMedia()` as in the spec.
---
### EFFECT: Scramble / Decode Text
**What it does (visual):** A dark, monospace page. Top-left a small eyebrow: a glowing `#d4ff3a` dot, a status word ("LISTENING") and "— ch. 04". Below it a large, light monospace headline, then a list of five nav rows separated by hairlines — each row has an index ("01"), a big label ("Index", "Studio", "Archive", "Journal", "Contact") and a muted mono meta ("142 entries"). On load the headline and the five labels are *not* readable: they are strings of random glyphs from the `alphanumeric` set (alphanumeric = `ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#@%&!`, binary = `01`, blocks = `▮▯░▒▓`, lowercase = `a–z`) of exactly the final length. 0.2s after load the headline starts "decoding": the glyphs keep churning for 0.3s, then real letters lock in from left to right (from the right when rightToLeft = false) until the full phrase "Signal from noise" is resolved 1.2s later. The five labels decode the same way, starting at 0.5s and each 0.1s after the previous, while their rows fade in. A footnote fades in at 1.6s. Afterwards: hovering (or keyboard-focusing) a row re-encodes just its label for 0.8s and draws a `#d4ff3a` hairline under the row; and every 3 seconds the status word decodes into the next one in the cycle LISTENING → DECODING → TRANSMITTING → RENDERING. The dot pulses slowly the whole time.
**Tech stack:**
- GSAP 3.13+ with ScrambleTextPlugin (free since 3.13). No ScrollTrigger needed.
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrambleTextPlugin.min.js"></script>`
- npm: `import gsap from "gsap"; import { ScrambleTextPlugin } from "gsap/ScrambleTextPlugin"; gsap.registerPlugin(ScrambleTextPlugin);`
- Font: a monospace with a light weight (demo: JetBrains Mono 300) — essential so scrambled glyphs don't change the line width.
**HTML structure:**
```html
<section class="scr">
<div class="scr__inner">
<p class="scr__eyebrow"><span class="scr__dot" aria-hidden="true"></span><span class="scr__status" data-words="LISTENING|DECODING|TRANSMITTING|RENDERING">LISTENING</span><span class="scr__channel">— ch. 04</span></p>
<h1 class="scr__title">Signal from noise</h1>
<nav class="scr__nav" aria-label="Sections">
<a class="scr__item" href="#index"><span class="scr__index">01</span><span class="scr__label">Index</span><span class="scr__meta">142 entries</span></a>
<a class="scr__item" href="#studio"><span class="scr__index">02</span><span class="scr__label">Studio</span><span class="scr__meta">Oslo · Lisbon</span></a>
<a class="scr__item" href="#archive"><span class="scr__index">03</span><span class="scr__label">Archive</span><span class="scr__meta">2014 — 2026</span></a>
<a class="scr__item" href="#journal"><span class="scr__index">04</span><span class="scr__label">Journal</span><span class="scr__meta">Weekly</span></a>
<a class="scr__item" href="#contact"><span class="scr__index">05</span><span class="scr__label">Contact</span><span class="scr__meta">Open for Q1</span></a>
</nav>
<p class="scr__footnote">Hover an item to re-encode it.</p>
</div>
</section>
```
**CSS requirements:**
- `.scr`: `min-height: 100vh; display: grid; place-items: center; padding: 8vw 6vw; background: var(--bg, #0a0a0a); color: var(--text, #ededed); font-family: var(--font-mono);`
- `.scr__inner`: `width: 100%; max-width: 1100px;`
- `.scr__eyebrow`: `display: flex; align-items: center; gap: .9em; font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted);` `.scr__status { color: var(--text); min-width: 13ch; }` (reserves the width of the longest word so the line never shifts).
- `.scr__dot`: `width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #d4ff3a); box-shadow: 0 0 12px var(--accent);`
- `.scr__title`: `font-weight: 300; font-size: clamp(2.4rem, 7vw, 6.5rem); line-height: 1; letter-spacing: -0.04em; margin: .6em 0 .7em;`
- `.scr__nav`: `display: grid; border-top: 1px solid var(--line);`
- `.scr__item`: `position: relative; display: grid; grid-template-columns: 3.5ch 1fr auto; align-items: baseline; gap: clamp(1rem, 3vw, 3rem); padding: clamp(.9rem, 1.8vw, 1.4rem) 0; border-bottom: 1px solid var(--line); text-decoration: none; color: inherit;`
- `.scr__item::after`: `content: ''; position: absolute; left: 0; bottom: -1px; height: 1px; width: 100%; background: var(--accent); transform: scaleX(0); transform-origin: left; transition: transform .5s cubic-bezier(.22, 1, .36, 1);` → `scaleX(1)` on `:hover` and `:focus-visible`.
- `.scr__index`, `.scr__meta`, `.scr__footnote`: 11px uppercase `letter-spacing: .12em; color: var(--muted);`
- `.scr__label`: `font-weight: 300; font-size: clamp(1.3rem, 3.2vw, 2.6rem); letter-spacing: -0.03em; line-height: 1;`
- Under 768px: `.scr__item { grid-template-columns: 3ch 1fr; } .scr__meta { display: none; }`
**Animation spec (exact values):**
Constants: `CHARS` = the `alphanumeric` glyph set (see above), `SPEED = 0.6`, `REVEAL_DELAY = 0.3`, `DURATION = 1.2`, `RTL = false`, `CYCLE = 3`.
1. Before anything renders: store each decode target's real copy in `el.dataset.text` and replace its `textContent` with a random string from `CHARS` of the same length (title + the five labels).
2. Load timeline `tl = gsap.timeline({ defaults: { ease: 'none' } })`:
- `tl.from('.scr__eyebrow', { opacity: 0, duration: 0.6 }, 0)`
- `tl.to(title, { duration: 1.2, scrambleText: { text: title.dataset.text, chars: CHARS, speed: 0.6, revealDelay: 0.3, tweenLength: false, rightToLeft: false } }, 0.2)` — 0.3s of noise, then letters resolve left → right, finished at 0.2 + 1.2s.
- `tl.from('.scr__item', { opacity: 0, duration: 0.5, stagger: 0.1 }, 0.5)` — rows fade in.
- For each label `i` (0–4): `tl.to(label, { duration: 1.2, scrambleText: { text: label.dataset.text, chars: CHARS, speed: 0.6, revealDelay: 0.3, tweenLength: false, rightToLeft: false } }, 0.5 + i * 0.1)`.
- `tl.from('.scr__footnote', { opacity: 0, duration: 0.6 }, 1.6)`.
3. Status cycle (words from `data-words`, split on `|`):
```js
let w = 0;
const cycle = () => { w = (w + 1) % words.length;
gsap.to(status, { duration: 1, ease: 'none', scrambleText: { text: words[w], chars: CHARS, speed: 0.6, revealDelay: 0.2, tweenLength: true, rightToLeft: false },
onComplete: () => gsap.delayedCall(3, cycle) }); };
gsap.delayedCall(3, cycle);
```
(`delayedCall` — not `setInterval` — so the cycle pauses with `gsap.globalTimeline`.)
4. Dot: `gsap.to('.scr__dot', { opacity: 0.25, duration: 0.9, yoyo: true, repeat: -1, ease: 'sine.inOut' })`.
5. Hover/focus on a row: `gsap.to(label, { duration: 0.8, ease: 'none', overwrite: 'auto', scrambleText: { text: label.dataset.text, chars: CHARS, speed: 0.6, revealDelay: 0.15, tweenLength: false, rightToLeft: false } })`.
**Interaction:** `pointerenter` and `focus` on `.scr__item` re-scramble that row's label (0.8s, resolves to the same word) and the lime hairline draws under the row over 0.5s (CSS transition). Leaving does nothing extra — the word is already resolved. Rows are real links.
**Responsive behavior:** All sizes are fluid. Under 768px the meta column is hidden and the row grid becomes `3ch 1fr`. Hover re-scramble simply doesn't occur on touch (no `pointerenter` without a hover-capable pointer); tapping a row follows the link. No `matchMedia` branches are needed for timing — the decode reads well at any size.
**Accessibility:** With `prefers-reduced-motion: reduce` do not pre-scramble or animate anything: the real headline, labels and status are shown immediately, the dot does not pulse and the underline transition is disabled. During the decode the DOM text is noise, so keep the decode short (≤ 1.5s) and keep the `<nav aria-label="Sections">` and `<h1>` semantics; links stay focusable with the underline doubling as the focus indicator (`:focus-visible`). The dot is `aria-hidden`.
**Performance rules:** ScrambleText writes `textContent` ~60×/s per active element — only 6 elements decode at once, which is trivial, but avoid scrambling whole paragraphs. All tweens, delayedCalls and the repeat live inside `gsap.context()`; `ctx.revert()` on unmount restores the original text and kills the cycle. Remove the hover/focus listeners in the same cleanup. The host's `globalTimeline` pause stops the status cycle and dot pulse off-screen.
**Customization knobs:**
- `CHARS` (`alphanumeric`) — the glyph alphabet (`binary` = `'01'` for a terminal feel, `blocks` = `'▮▯░▒▓'`, `lowercase` for softer noise).
- `SPEED` (0.6) — churn rate; `REVEAL_DELAY` (0.3) — how long it stays pure noise; `DURATION` (1.2).
- `CYCLE` (3) and `data-words` — the status vocabulary.
- `tweenLength: false` keeps widths stable; `true` lets short → long words grow.
- `rightToLeft` (false) — resolve from the right instead.
- Accent colour for the dot and hover hairline (`#d4ff3a`).
**Acceptance criteria:**
- [ ] Before 0.2s the headline and labels are unreadable noise of the correct length; no real word flashes first.
- [ ] Headline is fully resolved 1.2s after it starts (0.2s); labels resolve in a 0.1s cascade starting at 0.5s; footnote appears at 1.6s.
- [ ] Hover/focus re-scrambles only that label and draws the accent underline; rapid re-hovers don't stack.
- [ ] Status word changes every 3s via a 1s decode and the line width never shifts.
- [ ] Reduced-motion users see the final text immediately with no scrambling or pulsing.
- [ ] No layout shift (monospace, fixed lengths); no console errors.
---
**Reference implementation (vanilla, converts 1:1 — same class names, same values):**
```html
<section class="scr">
<div class="scr__inner">
<p class="scr__eyebrow"><span class="scr__dot" aria-hidden="true"></span><span class="scr__status" data-words="LISTENING|DECODING|TRANSMITTING|RENDERING">LISTENING</span><span class="scr__channel">— ch. 04</span></p>
<h1 class="scr__title">Signal from noise</h1>
<nav class="scr__nav" aria-label="Sections">
<a class="scr__item" href="#index"><span class="scr__index">01</span><span class="scr__label">Index</span><span class="scr__meta">142 entries</span></a>
<a class="scr__item" href="#studio"><span class="scr__index">02</span><span class="scr__label">Studio</span><span class="scr__meta">Oslo · Lisbon</span></a>
<a class="scr__item" href="#archive"><span class="scr__index">03</span><span class="scr__label">Archive</span><span class="scr__meta">2014 — 2026</span></a>
<a class="scr__item" href="#journal"><span class="scr__index">04</span><span class="scr__label">Journal</span><span class="scr__meta">Weekly</span></a>
<a class="scr__item" href="#contact"><span class="scr__index">05</span><span class="scr__label">Contact</span><span class="scr__meta">Open for Q1</span></a>
</nav>
<p class="scr__footnote">Hover an item to re-encode it.</p>
</div>
</section>
```
```css
.scr { --accent: var(--knob-accent, var(--accent)); min-height: 100vh; display: grid; place-items: center; padding: 8vw 6vw; background: var(--bg); color: var(--text); font-family: var(--font-mono); }
.scr__inner { width: 100%; max-width: 1100px; }
.scr__eyebrow { display: flex; align-items: center; gap: .9em; font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); }
.scr__dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 12px var(--accent); }
.scr__status { color: var(--text); min-width: 13ch; }
.scr__title { font-weight: 300; font-size: clamp(2.4rem, 7vw, 6.5rem); line-height: 1; letter-spacing: -0.04em; margin: .6em 0 .7em; }
.scr__nav { display: grid; border-top: 1px solid var(--line); }
.scr__item {
position: relative; display: grid; grid-template-columns: 3.5ch 1fr auto; align-items: baseline; gap: clamp(1rem, 3vw, 3rem);
padding: clamp(.9rem, 1.8vw, 1.4rem) 0; border-bottom: 1px solid var(--line); text-decoration: none; color: inherit;
}
.scr__item::after { content: ''; position: absolute; left: 0; bottom: -1px; height: 1px; width: 100%; background: var(--accent); transform: scaleX(0); transform-origin: left; transition: transform .5s cubic-bezier(.22, 1, .36, 1); }
.scr__item:hover::after, .scr__item:focus-visible::after { transform: scaleX(1); }
.scr__item:focus-visible { outline: none; }
.scr__index { font-size: 11px; letter-spacing: .12em; color: var(--muted); }
.scr__label { font-weight: 300; font-size: clamp(1.3rem, 3.2vw, 2.6rem); letter-spacing: -0.03em; line-height: 1; }
.scr__meta { font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); }
.scr__footnote { margin-top: 2rem; font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); }
@media (max-width: 767px) {
.scr__item { grid-template-columns: 3ch 1fr; }
.scr__meta { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.scr__dot { box-shadow: none; }
.scr__item::after { transition: none; }
}
```
```js
// Scramble / Decode Text — glyph noise resolves into real words on load, on hover and on a cycling status line.
gsap.registerPlugin(ScrambleTextPlugin);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { speed: 0.6, revealDelay: 0.3, duration: 1.2, chars: 'alphanumeric', rightToLeft: false, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const GLYPHS = {
alphanumeric: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#@%&!',
binary: '01',
blocks: '▮▯░▒▓',
lowercase: 'abcdefghijklmnopqrstuvwxyz',
};
const CYCLE = 3; // seconds between status words
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.scr');
const title = document.querySelector('.scr__title');
const items = gsap.utils.toArray('.scr__item');
const labels = gsap.utils.toArray('.scr__label');
const status = document.querySelector('.scr__status');
const footnote = document.querySelector('.scr__footnote');
const words = status.dataset.words.split('|');
// Remember the real copy once, before any noise is written.
[title, ...labels].forEach((el) => { el.dataset.text = el.textContent; });
let listeners = [];
const unbind = () => {
listeners.forEach(([el, fn]) => { el.removeEventListener('pointerenter', fn); el.removeEventListener('focus', fn); });
listeners = [];
};
let ctx;
function build() {
ctx?.revert();
unbind();
const CHARS = GLYPHS[K('chars')] ?? GLYPHS.alphanumeric;
const SPEED = K('speed'); // how fast the random glyphs churn
const REVEAL_DELAY = K('revealDelay'); // seconds of pure noise before letters start resolving
const DURATION = K('duration'); // per-element decode time
const rightToLeft = !!K('rightToLeft');
section.style.setProperty('--knob-accent', K('accent'));
const scramble = (len) => Array.from({ length: len }, () => CHARS[Math.floor(Math.random() * CHARS.length)]).join('');
status.textContent = words[0];
ctx = gsap.context(() => {
if (reduced) { [title, ...labels].forEach((el) => { el.textContent = el.dataset.text; }); return; } // real text, no noise
// Pre-fill with noise so nothing readable flashes before the decode.
[title, ...labels].forEach((el) => { el.textContent = scramble(el.dataset.text.length); });
const decode = (el, duration, revealDelay, position) => tl.to(el, {
duration, scrambleText: { text: el.dataset.text, chars: CHARS, speed: SPEED, revealDelay, tweenLength: false, rightToLeft },
}, position);
const tl = gsap.timeline({ defaults: { ease: 'none' } });
tl.from('.scr__eyebrow', { opacity: 0, duration: 0.6 }, 0)
.from(items, { opacity: 0, duration: 0.5, stagger: 0.1 }, 0.5)
.from(footnote, { opacity: 0, duration: 0.6 }, 1.6);
decode(title, DURATION, REVEAL_DELAY, 0.2);
labels.forEach((label, i) => decode(label, DURATION, REVEAL_DELAY, 0.5 + i * 0.1));
// Status line: a new word decodes every 3 seconds (delayedCall lives on the global timeline, so it pauses with the host).
let w = 0;
const cycle = () => {
w = (w + 1) % words.length;
gsap.to(status, {
duration: 1, ease: 'none',
scrambleText: { text: words[w], chars: CHARS, speed: SPEED, revealDelay: 0.2, tweenLength: true, rightToLeft },
onComplete: () => gsap.delayedCall(CYCLE, cycle),
});
};
gsap.delayedCall(CYCLE, cycle);
gsap.to('.scr__dot', { opacity: 0.25, duration: 0.9, yoyo: true, repeat: -1, ease: 'sine.inOut' });
// Hover: re-encode just that label.
items.forEach((item) => {
const label = item.querySelector('.scr__label');
const onEnter = () => gsap.to(label, {
duration: 0.8, ease: 'none', overwrite: 'auto',
scrambleText: { text: label.dataset.text, chars: CHARS, speed: SPEED, revealDelay: 0.15, tweenLength: false, rightToLeft },
});
item.addEventListener('pointerenter', onEnter);
item.addEventListener('focus', onEnter);
listeners.push([item, onEnter]);
});
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => { unbind(); ctx?.revert(); });
```
### EFFECT (single HTML file): Scramble / Decode Text
Produce **one self-contained `index.html`** (inline `<style>` and `<script type="module">`, no build step) that implements **Scramble / Decode Text** exactly as specified below. Libraries load from jsDelivr:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scramble / Decode Text</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..600&family=Inter+Tight:wght@300..700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--bg:#0a0a0a;--surface:#121212;--line:rgba(255,255,255,.08);--text:#ededed;--muted:#8a8a8a;--accent:#d4ff3a;--font-display:"Fraunces",Georgia,serif;--font-sans:"Inter Tight",system-ui,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,monospace}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;overflow-x:hidden}
/* + the CSS requirements below */
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrambleTextPlugin.min.js"></script>
</head>
<body>
<!-- markup from the HTML structure section -->
<script type="module">
// the JS from the animation spec (window.MotionLab is optional — keep the ?. guards or remove them)
</script>
</body>
</html>
```
The file must open directly from a local server (`npx serve .`) with zero console errors, work at 375px and 1440px, and honour `prefers-reduced-motion`.
---
### EFFECT: Scramble / Decode Text
**What it does (visual):** A dark, monospace page. Top-left a small eyebrow: a glowing `#d4ff3a` dot, a status word ("LISTENING") and "— ch. 04". Below it a large, light monospace headline, then a list of five nav rows separated by hairlines — each row has an index ("01"), a big label ("Index", "Studio", "Archive", "Journal", "Contact") and a muted mono meta ("142 entries"). On load the headline and the five labels are *not* readable: they are strings of random glyphs from the `alphanumeric` set (alphanumeric = `ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#@%&!`, binary = `01`, blocks = `▮▯░▒▓`, lowercase = `a–z`) of exactly the final length. 0.2s after load the headline starts "decoding": the glyphs keep churning for 0.3s, then real letters lock in from left to right (from the right when rightToLeft = false) until the full phrase "Signal from noise" is resolved 1.2s later. The five labels decode the same way, starting at 0.5s and each 0.1s after the previous, while their rows fade in. A footnote fades in at 1.6s. Afterwards: hovering (or keyboard-focusing) a row re-encodes just its label for 0.8s and draws a `#d4ff3a` hairline under the row; and every 3 seconds the status word decodes into the next one in the cycle LISTENING → DECODING → TRANSMITTING → RENDERING. The dot pulses slowly the whole time.
**Tech stack:**
- GSAP 3.13+ with ScrambleTextPlugin (free since 3.13). No ScrollTrigger needed.
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrambleTextPlugin.min.js"></script>`
- npm: `import gsap from "gsap"; import { ScrambleTextPlugin } from "gsap/ScrambleTextPlugin"; gsap.registerPlugin(ScrambleTextPlugin);`
- Font: a monospace with a light weight (demo: JetBrains Mono 300) — essential so scrambled glyphs don't change the line width.
**HTML structure:**
```html
<section class="scr">
<div class="scr__inner">
<p class="scr__eyebrow"><span class="scr__dot" aria-hidden="true"></span><span class="scr__status" data-words="LISTENING|DECODING|TRANSMITTING|RENDERING">LISTENING</span><span class="scr__channel">— ch. 04</span></p>
<h1 class="scr__title">Signal from noise</h1>
<nav class="scr__nav" aria-label="Sections">
<a class="scr__item" href="#index"><span class="scr__index">01</span><span class="scr__label">Index</span><span class="scr__meta">142 entries</span></a>
<a class="scr__item" href="#studio"><span class="scr__index">02</span><span class="scr__label">Studio</span><span class="scr__meta">Oslo · Lisbon</span></a>
<a class="scr__item" href="#archive"><span class="scr__index">03</span><span class="scr__label">Archive</span><span class="scr__meta">2014 — 2026</span></a>
<a class="scr__item" href="#journal"><span class="scr__index">04</span><span class="scr__label">Journal</span><span class="scr__meta">Weekly</span></a>
<a class="scr__item" href="#contact"><span class="scr__index">05</span><span class="scr__label">Contact</span><span class="scr__meta">Open for Q1</span></a>
</nav>
<p class="scr__footnote">Hover an item to re-encode it.</p>
</div>
</section>
```
**CSS requirements:**
- `.scr`: `min-height: 100vh; display: grid; place-items: center; padding: 8vw 6vw; background: var(--bg, #0a0a0a); color: var(--text, #ededed); font-family: var(--font-mono);`
- `.scr__inner`: `width: 100%; max-width: 1100px;`
- `.scr__eyebrow`: `display: flex; align-items: center; gap: .9em; font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted);` `.scr__status { color: var(--text); min-width: 13ch; }` (reserves the width of the longest word so the line never shifts).
- `.scr__dot`: `width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #d4ff3a); box-shadow: 0 0 12px var(--accent);`
- `.scr__title`: `font-weight: 300; font-size: clamp(2.4rem, 7vw, 6.5rem); line-height: 1; letter-spacing: -0.04em; margin: .6em 0 .7em;`
- `.scr__nav`: `display: grid; border-top: 1px solid var(--line);`
- `.scr__item`: `position: relative; display: grid; grid-template-columns: 3.5ch 1fr auto; align-items: baseline; gap: clamp(1rem, 3vw, 3rem); padding: clamp(.9rem, 1.8vw, 1.4rem) 0; border-bottom: 1px solid var(--line); text-decoration: none; color: inherit;`
- `.scr__item::after`: `content: ''; position: absolute; left: 0; bottom: -1px; height: 1px; width: 100%; background: var(--accent); transform: scaleX(0); transform-origin: left; transition: transform .5s cubic-bezier(.22, 1, .36, 1);` → `scaleX(1)` on `:hover` and `:focus-visible`.
- `.scr__index`, `.scr__meta`, `.scr__footnote`: 11px uppercase `letter-spacing: .12em; color: var(--muted);`
- `.scr__label`: `font-weight: 300; font-size: clamp(1.3rem, 3.2vw, 2.6rem); letter-spacing: -0.03em; line-height: 1;`
- Under 768px: `.scr__item { grid-template-columns: 3ch 1fr; } .scr__meta { display: none; }`
**Animation spec (exact values):**
Constants: `CHARS` = the `alphanumeric` glyph set (see above), `SPEED = 0.6`, `REVEAL_DELAY = 0.3`, `DURATION = 1.2`, `RTL = false`, `CYCLE = 3`.
1. Before anything renders: store each decode target's real copy in `el.dataset.text` and replace its `textContent` with a random string from `CHARS` of the same length (title + the five labels).
2. Load timeline `tl = gsap.timeline({ defaults: { ease: 'none' } })`:
- `tl.from('.scr__eyebrow', { opacity: 0, duration: 0.6 }, 0)`
- `tl.to(title, { duration: 1.2, scrambleText: { text: title.dataset.text, chars: CHARS, speed: 0.6, revealDelay: 0.3, tweenLength: false, rightToLeft: false } }, 0.2)` — 0.3s of noise, then letters resolve left → right, finished at 0.2 + 1.2s.
- `tl.from('.scr__item', { opacity: 0, duration: 0.5, stagger: 0.1 }, 0.5)` — rows fade in.
- For each label `i` (0–4): `tl.to(label, { duration: 1.2, scrambleText: { text: label.dataset.text, chars: CHARS, speed: 0.6, revealDelay: 0.3, tweenLength: false, rightToLeft: false } }, 0.5 + i * 0.1)`.
- `tl.from('.scr__footnote', { opacity: 0, duration: 0.6 }, 1.6)`.
3. Status cycle (words from `data-words`, split on `|`):
```js
let w = 0;
const cycle = () => { w = (w + 1) % words.length;
gsap.to(status, { duration: 1, ease: 'none', scrambleText: { text: words[w], chars: CHARS, speed: 0.6, revealDelay: 0.2, tweenLength: true, rightToLeft: false },
onComplete: () => gsap.delayedCall(3, cycle) }); };
gsap.delayedCall(3, cycle);
```
(`delayedCall` — not `setInterval` — so the cycle pauses with `gsap.globalTimeline`.)
4. Dot: `gsap.to('.scr__dot', { opacity: 0.25, duration: 0.9, yoyo: true, repeat: -1, ease: 'sine.inOut' })`.
5. Hover/focus on a row: `gsap.to(label, { duration: 0.8, ease: 'none', overwrite: 'auto', scrambleText: { text: label.dataset.text, chars: CHARS, speed: 0.6, revealDelay: 0.15, tweenLength: false, rightToLeft: false } })`.
**Interaction:** `pointerenter` and `focus` on `.scr__item` re-scramble that row's label (0.8s, resolves to the same word) and the lime hairline draws under the row over 0.5s (CSS transition). Leaving does nothing extra — the word is already resolved. Rows are real links.
**Responsive behavior:** All sizes are fluid. Under 768px the meta column is hidden and the row grid becomes `3ch 1fr`. Hover re-scramble simply doesn't occur on touch (no `pointerenter` without a hover-capable pointer); tapping a row follows the link. No `matchMedia` branches are needed for timing — the decode reads well at any size.
**Accessibility:** With `prefers-reduced-motion: reduce` do not pre-scramble or animate anything: the real headline, labels and status are shown immediately, the dot does not pulse and the underline transition is disabled. During the decode the DOM text is noise, so keep the decode short (≤ 1.5s) and keep the `<nav aria-label="Sections">` and `<h1>` semantics; links stay focusable with the underline doubling as the focus indicator (`:focus-visible`). The dot is `aria-hidden`.
**Performance rules:** ScrambleText writes `textContent` ~60×/s per active element — only 6 elements decode at once, which is trivial, but avoid scrambling whole paragraphs. All tweens, delayedCalls and the repeat live inside `gsap.context()`; `ctx.revert()` on unmount restores the original text and kills the cycle. Remove the hover/focus listeners in the same cleanup. The host's `globalTimeline` pause stops the status cycle and dot pulse off-screen.
**Customization knobs:**
- `CHARS` (`alphanumeric`) — the glyph alphabet (`binary` = `'01'` for a terminal feel, `blocks` = `'▮▯░▒▓'`, `lowercase` for softer noise).
- `SPEED` (0.6) — churn rate; `REVEAL_DELAY` (0.3) — how long it stays pure noise; `DURATION` (1.2).
- `CYCLE` (3) and `data-words` — the status vocabulary.
- `tweenLength: false` keeps widths stable; `true` lets short → long words grow.
- `rightToLeft` (false) — resolve from the right instead.
- Accent colour for the dot and hover hairline (`#d4ff3a`).
**Acceptance criteria:**
- [ ] Before 0.2s the headline and labels are unreadable noise of the correct length; no real word flashes first.
- [ ] Headline is fully resolved 1.2s after it starts (0.2s); labels resolve in a 0.1s cascade starting at 0.5s; footnote appears at 1.6s.
- [ ] Hover/focus re-scrambles only that label and draws the accent underline; rapid re-hovers don't stack.
- [ ] Status word changes every 3s via a 1s decode and the line width never shifts.
- [ ] Reduced-motion users see the final text immediately with no scrambling or pulsing.
- [ ] No layout shift (monospace, fixed lengths); no console errors.
---
**Reference implementation (use these exact values and class names):**
```html
<section class="scr">
<div class="scr__inner">
<p class="scr__eyebrow"><span class="scr__dot" aria-hidden="true"></span><span class="scr__status" data-words="LISTENING|DECODING|TRANSMITTING|RENDERING">LISTENING</span><span class="scr__channel">— ch. 04</span></p>
<h1 class="scr__title">Signal from noise</h1>
<nav class="scr__nav" aria-label="Sections">
<a class="scr__item" href="#index"><span class="scr__index">01</span><span class="scr__label">Index</span><span class="scr__meta">142 entries</span></a>
<a class="scr__item" href="#studio"><span class="scr__index">02</span><span class="scr__label">Studio</span><span class="scr__meta">Oslo · Lisbon</span></a>
<a class="scr__item" href="#archive"><span class="scr__index">03</span><span class="scr__label">Archive</span><span class="scr__meta">2014 — 2026</span></a>
<a class="scr__item" href="#journal"><span class="scr__index">04</span><span class="scr__label">Journal</span><span class="scr__meta">Weekly</span></a>
<a class="scr__item" href="#contact"><span class="scr__index">05</span><span class="scr__label">Contact</span><span class="scr__meta">Open for Q1</span></a>
</nav>
<p class="scr__footnote">Hover an item to re-encode it.</p>
</div>
</section>
```
```css
.scr { --accent: var(--knob-accent, var(--accent)); min-height: 100vh; display: grid; place-items: center; padding: 8vw 6vw; background: var(--bg); color: var(--text); font-family: var(--font-mono); }
.scr__inner { width: 100%; max-width: 1100px; }
.scr__eyebrow { display: flex; align-items: center; gap: .9em; font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); }
.scr__dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 12px var(--accent); }
.scr__status { color: var(--text); min-width: 13ch; }
.scr__title { font-weight: 300; font-size: clamp(2.4rem, 7vw, 6.5rem); line-height: 1; letter-spacing: -0.04em; margin: .6em 0 .7em; }
.scr__nav { display: grid; border-top: 1px solid var(--line); }
.scr__item {
position: relative; display: grid; grid-template-columns: 3.5ch 1fr auto; align-items: baseline; gap: clamp(1rem, 3vw, 3rem);
padding: clamp(.9rem, 1.8vw, 1.4rem) 0; border-bottom: 1px solid var(--line); text-decoration: none; color: inherit;
}
.scr__item::after { content: ''; position: absolute; left: 0; bottom: -1px; height: 1px; width: 100%; background: var(--accent); transform: scaleX(0); transform-origin: left; transition: transform .5s cubic-bezier(.22, 1, .36, 1); }
.scr__item:hover::after, .scr__item:focus-visible::after { transform: scaleX(1); }
.scr__item:focus-visible { outline: none; }
.scr__index { font-size: 11px; letter-spacing: .12em; color: var(--muted); }
.scr__label { font-weight: 300; font-size: clamp(1.3rem, 3.2vw, 2.6rem); letter-spacing: -0.03em; line-height: 1; }
.scr__meta { font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); }
.scr__footnote { margin-top: 2rem; font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); }
@media (max-width: 767px) {
.scr__item { grid-template-columns: 3ch 1fr; }
.scr__meta { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.scr__dot { box-shadow: none; }
.scr__item::after { transition: none; }
}
```
```js
// Scramble / Decode Text — glyph noise resolves into real words on load, on hover and on a cycling status line.
gsap.registerPlugin(ScrambleTextPlugin);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { speed: 0.6, revealDelay: 0.3, duration: 1.2, chars: 'alphanumeric', rightToLeft: false, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const GLYPHS = {
alphanumeric: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#@%&!',
binary: '01',
blocks: '▮▯░▒▓',
lowercase: 'abcdefghijklmnopqrstuvwxyz',
};
const CYCLE = 3; // seconds between status words
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.scr');
const title = document.querySelector('.scr__title');
const items = gsap.utils.toArray('.scr__item');
const labels = gsap.utils.toArray('.scr__label');
const status = document.querySelector('.scr__status');
const footnote = document.querySelector('.scr__footnote');
const words = status.dataset.words.split('|');
// Remember the real copy once, before any noise is written.
[title, ...labels].forEach((el) => { el.dataset.text = el.textContent; });
let listeners = [];
const unbind = () => {
listeners.forEach(([el, fn]) => { el.removeEventListener('pointerenter', fn); el.removeEventListener('focus', fn); });
listeners = [];
};
let ctx;
function build() {
ctx?.revert();
unbind();
const CHARS = GLYPHS[K('chars')] ?? GLYPHS.alphanumeric;
const SPEED = K('speed'); // how fast the random glyphs churn
const REVEAL_DELAY = K('revealDelay'); // seconds of pure noise before letters start resolving
const DURATION = K('duration'); // per-element decode time
const rightToLeft = !!K('rightToLeft');
section.style.setProperty('--knob-accent', K('accent'));
const scramble = (len) => Array.from({ length: len }, () => CHARS[Math.floor(Math.random() * CHARS.length)]).join('');
status.textContent = words[0];
ctx = gsap.context(() => {
if (reduced) { [title, ...labels].forEach((el) => { el.textContent = el.dataset.text; }); return; } // real text, no noise
// Pre-fill with noise so nothing readable flashes before the decode.
[title, ...labels].forEach((el) => { el.textContent = scramble(el.dataset.text.length); });
const decode = (el, duration, revealDelay, position) => tl.to(el, {
duration, scrambleText: { text: el.dataset.text, chars: CHARS, speed: SPEED, revealDelay, tweenLength: false, rightToLeft },
}, position);
const tl = gsap.timeline({ defaults: { ease: 'none' } });
tl.from('.scr__eyebrow', { opacity: 0, duration: 0.6 }, 0)
.from(items, { opacity: 0, duration: 0.5, stagger: 0.1 }, 0.5)
.from(footnote, { opacity: 0, duration: 0.6 }, 1.6);
decode(title, DURATION, REVEAL_DELAY, 0.2);
labels.forEach((label, i) => decode(label, DURATION, REVEAL_DELAY, 0.5 + i * 0.1));
// Status line: a new word decodes every 3 seconds (delayedCall lives on the global timeline, so it pauses with the host).
let w = 0;
const cycle = () => {
w = (w + 1) % words.length;
gsap.to(status, {
duration: 1, ease: 'none',
scrambleText: { text: words[w], chars: CHARS, speed: SPEED, revealDelay: 0.2, tweenLength: true, rightToLeft },
onComplete: () => gsap.delayedCall(CYCLE, cycle),
});
};
gsap.delayedCall(CYCLE, cycle);
gsap.to('.scr__dot', { opacity: 0.25, duration: 0.9, yoyo: true, repeat: -1, ease: 'sine.inOut' });
// Hover: re-encode just that label.
items.forEach((item) => {
const label = item.querySelector('.scr__label');
const onEnter = () => gsap.to(label, {
duration: 0.8, ease: 'none', overwrite: 'auto',
scrambleText: { text: label.dataset.text, chars: CHARS, speed: SPEED, revealDelay: 0.15, tweenLength: false, rightToLeft },
});
item.addEventListener('pointerenter', onEnter);
item.addEventListener('focus', onEnter);
listeners.push([item, onEnter]);
});
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => { unbind(); ctx?.revert(); });
```
### EFFECT (Framer & Webflow notes): Scramble / Decode Text
**Framer**
- Implement as a **Code Component** (Assets → Code → New component) when the effect owns its own markup (hero, gallery, section), or as a **Code Override** when it should animate existing Framer layers.
- Code Component: paste the React version (`"use client"` is not needed in Framer; keep `useGSAP` with a `scope` ref). Install `gsap` and `@gsap/react` from the package panel. Expose the customization knobs as `addPropertyControls` (numbers/colors/enums) so they are editable on the canvas.
- Code Override: `export function withScrambleDecodeText(Component) { return (props) => { const ref = useRef(null); useGSAP(() => { /* spec */ }, { scope: ref }); return <Component ref={ref} {...props} /> } }` and apply it to the layer in the right-hand panel.
- Framer already smooth-scrolls; do **not** add Lenis.
- Preview in the Framer preview window, not the canvas (GSAP runs at runtime only).
**Webflow**
- Build the markup in the Designer with **exactly the class names from the HTML structure section** (prefix `scr`), so the script finds the elements.
- Styles: either recreate the CSS requirements as Webflow classes or paste the CSS in **Page settings → Custom code → Inside <head>** inside `<style>`.
- Scripts: **Page settings → Before </body>**:
```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrambleTextPlugin.min.js"></script>
<script type="module">
// paste the JS from the animation spec; remove the optional window.MotionLab?.… lines
</script>
```
- Native alternative: parts that are pure show/hide, fade or move can be Webflow Interactions (IX2); keep the timeline sequencing in code — IX2 cannot do stagger/ease control at this precision.
- Custom code only runs on the **published** site or in Preview, never on the Designer canvas.
- Webflow's own smooth scroll is off by default; if you add Lenis, sync it: `lenis.on('scroll', ScrollTrigger.update)`.
---
### EFFECT: Scramble / Decode Text
**What it does (visual):** A dark, monospace page. Top-left a small eyebrow: a glowing `#d4ff3a` dot, a status word ("LISTENING") and "— ch. 04". Below it a large, light monospace headline, then a list of five nav rows separated by hairlines — each row has an index ("01"), a big label ("Index", "Studio", "Archive", "Journal", "Contact") and a muted mono meta ("142 entries"). On load the headline and the five labels are *not* readable: they are strings of random glyphs from the `alphanumeric` set (alphanumeric = `ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#@%&!`, binary = `01`, blocks = `▮▯░▒▓`, lowercase = `a–z`) of exactly the final length. 0.2s after load the headline starts "decoding": the glyphs keep churning for 0.3s, then real letters lock in from left to right (from the right when rightToLeft = false) until the full phrase "Signal from noise" is resolved 1.2s later. The five labels decode the same way, starting at 0.5s and each 0.1s after the previous, while their rows fade in. A footnote fades in at 1.6s. Afterwards: hovering (or keyboard-focusing) a row re-encodes just its label for 0.8s and draws a `#d4ff3a` hairline under the row; and every 3 seconds the status word decodes into the next one in the cycle LISTENING → DECODING → TRANSMITTING → RENDERING. The dot pulses slowly the whole time.
**Tech stack:**
- GSAP 3.13+ with ScrambleTextPlugin (free since 3.13). No ScrollTrigger needed.
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrambleTextPlugin.min.js"></script>`
- npm: `import gsap from "gsap"; import { ScrambleTextPlugin } from "gsap/ScrambleTextPlugin"; gsap.registerPlugin(ScrambleTextPlugin);`
- Font: a monospace with a light weight (demo: JetBrains Mono 300) — essential so scrambled glyphs don't change the line width.
**HTML structure:**
```html
<section class="scr">
<div class="scr__inner">
<p class="scr__eyebrow"><span class="scr__dot" aria-hidden="true"></span><span class="scr__status" data-words="LISTENING|DECODING|TRANSMITTING|RENDERING">LISTENING</span><span class="scr__channel">— ch. 04</span></p>
<h1 class="scr__title">Signal from noise</h1>
<nav class="scr__nav" aria-label="Sections">
<a class="scr__item" href="#index"><span class="scr__index">01</span><span class="scr__label">Index</span><span class="scr__meta">142 entries</span></a>
<a class="scr__item" href="#studio"><span class="scr__index">02</span><span class="scr__label">Studio</span><span class="scr__meta">Oslo · Lisbon</span></a>
<a class="scr__item" href="#archive"><span class="scr__index">03</span><span class="scr__label">Archive</span><span class="scr__meta">2014 — 2026</span></a>
<a class="scr__item" href="#journal"><span class="scr__index">04</span><span class="scr__label">Journal</span><span class="scr__meta">Weekly</span></a>
<a class="scr__item" href="#contact"><span class="scr__index">05</span><span class="scr__label">Contact</span><span class="scr__meta">Open for Q1</span></a>
</nav>
<p class="scr__footnote">Hover an item to re-encode it.</p>
</div>
</section>
```
**CSS requirements:**
- `.scr`: `min-height: 100vh; display: grid; place-items: center; padding: 8vw 6vw; background: var(--bg, #0a0a0a); color: var(--text, #ededed); font-family: var(--font-mono);`
- `.scr__inner`: `width: 100%; max-width: 1100px;`
- `.scr__eyebrow`: `display: flex; align-items: center; gap: .9em; font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted);` `.scr__status { color: var(--text); min-width: 13ch; }` (reserves the width of the longest word so the line never shifts).
- `.scr__dot`: `width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #d4ff3a); box-shadow: 0 0 12px var(--accent);`
- `.scr__title`: `font-weight: 300; font-size: clamp(2.4rem, 7vw, 6.5rem); line-height: 1; letter-spacing: -0.04em; margin: .6em 0 .7em;`
- `.scr__nav`: `display: grid; border-top: 1px solid var(--line);`
- `.scr__item`: `position: relative; display: grid; grid-template-columns: 3.5ch 1fr auto; align-items: baseline; gap: clamp(1rem, 3vw, 3rem); padding: clamp(.9rem, 1.8vw, 1.4rem) 0; border-bottom: 1px solid var(--line); text-decoration: none; color: inherit;`
- `.scr__item::after`: `content: ''; position: absolute; left: 0; bottom: -1px; height: 1px; width: 100%; background: var(--accent); transform: scaleX(0); transform-origin: left; transition: transform .5s cubic-bezier(.22, 1, .36, 1);` → `scaleX(1)` on `:hover` and `:focus-visible`.
- `.scr__index`, `.scr__meta`, `.scr__footnote`: 11px uppercase `letter-spacing: .12em; color: var(--muted);`
- `.scr__label`: `font-weight: 300; font-size: clamp(1.3rem, 3.2vw, 2.6rem); letter-spacing: -0.03em; line-height: 1;`
- Under 768px: `.scr__item { grid-template-columns: 3ch 1fr; } .scr__meta { display: none; }`
**Animation spec (exact values):**
Constants: `CHARS` = the `alphanumeric` glyph set (see above), `SPEED = 0.6`, `REVEAL_DELAY = 0.3`, `DURATION = 1.2`, `RTL = false`, `CYCLE = 3`.
1. Before anything renders: store each decode target's real copy in `el.dataset.text` and replace its `textContent` with a random string from `CHARS` of the same length (title + the five labels).
2. Load timeline `tl = gsap.timeline({ defaults: { ease: 'none' } })`:
- `tl.from('.scr__eyebrow', { opacity: 0, duration: 0.6 }, 0)`
- `tl.to(title, { duration: 1.2, scrambleText: { text: title.dataset.text, chars: CHARS, speed: 0.6, revealDelay: 0.3, tweenLength: false, rightToLeft: false } }, 0.2)` — 0.3s of noise, then letters resolve left → right, finished at 0.2 + 1.2s.
- `tl.from('.scr__item', { opacity: 0, duration: 0.5, stagger: 0.1 }, 0.5)` — rows fade in.
- For each label `i` (0–4): `tl.to(label, { duration: 1.2, scrambleText: { text: label.dataset.text, chars: CHARS, speed: 0.6, revealDelay: 0.3, tweenLength: false, rightToLeft: false } }, 0.5 + i * 0.1)`.
- `tl.from('.scr__footnote', { opacity: 0, duration: 0.6 }, 1.6)`.
3. Status cycle (words from `data-words`, split on `|`):
```js
let w = 0;
const cycle = () => { w = (w + 1) % words.length;
gsap.to(status, { duration: 1, ease: 'none', scrambleText: { text: words[w], chars: CHARS, speed: 0.6, revealDelay: 0.2, tweenLength: true, rightToLeft: false },
onComplete: () => gsap.delayedCall(3, cycle) }); };
gsap.delayedCall(3, cycle);
```
(`delayedCall` — not `setInterval` — so the cycle pauses with `gsap.globalTimeline`.)
4. Dot: `gsap.to('.scr__dot', { opacity: 0.25, duration: 0.9, yoyo: true, repeat: -1, ease: 'sine.inOut' })`.
5. Hover/focus on a row: `gsap.to(label, { duration: 0.8, ease: 'none', overwrite: 'auto', scrambleText: { text: label.dataset.text, chars: CHARS, speed: 0.6, revealDelay: 0.15, tweenLength: false, rightToLeft: false } })`.
**Interaction:** `pointerenter` and `focus` on `.scr__item` re-scramble that row's label (0.8s, resolves to the same word) and the lime hairline draws under the row over 0.5s (CSS transition). Leaving does nothing extra — the word is already resolved. Rows are real links.
**Responsive behavior:** All sizes are fluid. Under 768px the meta column is hidden and the row grid becomes `3ch 1fr`. Hover re-scramble simply doesn't occur on touch (no `pointerenter` without a hover-capable pointer); tapping a row follows the link. No `matchMedia` branches are needed for timing — the decode reads well at any size.
**Accessibility:** With `prefers-reduced-motion: reduce` do not pre-scramble or animate anything: the real headline, labels and status are shown immediately, the dot does not pulse and the underline transition is disabled. During the decode the DOM text is noise, so keep the decode short (≤ 1.5s) and keep the `<nav aria-label="Sections">` and `<h1>` semantics; links stay focusable with the underline doubling as the focus indicator (`:focus-visible`). The dot is `aria-hidden`.
**Performance rules:** ScrambleText writes `textContent` ~60×/s per active element — only 6 elements decode at once, which is trivial, but avoid scrambling whole paragraphs. All tweens, delayedCalls and the repeat live inside `gsap.context()`; `ctx.revert()` on unmount restores the original text and kills the cycle. Remove the hover/focus listeners in the same cleanup. The host's `globalTimeline` pause stops the status cycle and dot pulse off-screen.
**Customization knobs:**
- `CHARS` (`alphanumeric`) — the glyph alphabet (`binary` = `'01'` for a terminal feel, `blocks` = `'▮▯░▒▓'`, `lowercase` for softer noise).
- `SPEED` (0.6) — churn rate; `REVEAL_DELAY` (0.3) — how long it stays pure noise; `DURATION` (1.2).
- `CYCLE` (3) and `data-words` — the status vocabulary.
- `tweenLength: false` keeps widths stable; `true` lets short → long words grow.
- `rightToLeft` (false) — resolve from the right instead.
- Accent colour for the dot and hover hairline (`#d4ff3a`).
**Acceptance criteria:**
- [ ] Before 0.2s the headline and labels are unreadable noise of the correct length; no real word flashes first.
- [ ] Headline is fully resolved 1.2s after it starts (0.2s); labels resolve in a 0.1s cascade starting at 0.5s; footnote appears at 1.6s.
- [ ] Hover/focus re-scrambles only that label and draws the accent underline; rapid re-hovers don't stack.
- [ ] Status word changes every 3s via a 1s decode and the line width never shifts.
- [ ] Reduced-motion users see the final text immediately with no scrambling or pulsing.
- [ ] No layout shift (monospace, fixed lengths); no console errors.
Source
Full source code
<section class="scr">
<div class="scr__inner">
<p class="scr__eyebrow"><span class="scr__dot" aria-hidden="true"></span><span class="scr__status" data-words="LISTENING|DECODING|TRANSMITTING|RENDERING">LISTENING</span><span class="scr__channel">— ch. 04</span></p>
<h1 class="scr__title">Signal from noise</h1>
<nav class="scr__nav" aria-label="Sections">
<a class="scr__item" href="#index"><span class="scr__index">01</span><span class="scr__label">Index</span><span class="scr__meta">142 entries</span></a>
<a class="scr__item" href="#studio"><span class="scr__index">02</span><span class="scr__label">Studio</span><span class="scr__meta">Oslo · Lisbon</span></a>
<a class="scr__item" href="#archive"><span class="scr__index">03</span><span class="scr__label">Archive</span><span class="scr__meta">2014 — 2026</span></a>
<a class="scr__item" href="#journal"><span class="scr__index">04</span><span class="scr__label">Journal</span><span class="scr__meta">Weekly</span></a>
<a class="scr__item" href="#contact"><span class="scr__index">05</span><span class="scr__label">Contact</span><span class="scr__meta">Open for Q1</span></a>
</nav>
<p class="scr__footnote">Hover an item to re-encode it.</p>
</div>
</section>
.scr { --accent: var(--knob-accent, var(--accent)); min-height: 100vh; display: grid; place-items: center; padding: 8vw 6vw; background: var(--bg); color: var(--text); font-family: var(--font-mono); }
.scr__inner { width: 100%; max-width: 1100px; }
.scr__eyebrow { display: flex; align-items: center; gap: .9em; font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); }
.scr__dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 12px var(--accent); }
.scr__status { color: var(--text); min-width: 13ch; }
.scr__title { font-weight: 300; font-size: clamp(2.4rem, 7vw, 6.5rem); line-height: 1; letter-spacing: -0.04em; margin: .6em 0 .7em; }
.scr__nav { display: grid; border-top: 1px solid var(--line); }
.scr__item {
position: relative; display: grid; grid-template-columns: 3.5ch 1fr auto; align-items: baseline; gap: clamp(1rem, 3vw, 3rem);
padding: clamp(.9rem, 1.8vw, 1.4rem) 0; border-bottom: 1px solid var(--line); text-decoration: none; color: inherit;
}
.scr__item::after { content: ''; position: absolute; left: 0; bottom: -1px; height: 1px; width: 100%; background: var(--accent); transform: scaleX(0); transform-origin: left; transition: transform .5s cubic-bezier(.22, 1, .36, 1); }
.scr__item:hover::after, .scr__item:focus-visible::after { transform: scaleX(1); }
.scr__item:focus-visible { outline: none; }
.scr__index { font-size: 11px; letter-spacing: .12em; color: var(--muted); }
.scr__label { font-weight: 300; font-size: clamp(1.3rem, 3.2vw, 2.6rem); letter-spacing: -0.03em; line-height: 1; }
.scr__meta { font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); }
.scr__footnote { margin-top: 2rem; font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); }
@media (max-width: 767px) {
.scr__item { grid-template-columns: 3ch 1fr; }
.scr__meta { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.scr__dot { box-shadow: none; }
.scr__item::after { transition: none; }
}
// Scramble / Decode Text — glyph noise resolves into real words on load, on hover and on a cycling status line.
gsap.registerPlugin(ScrambleTextPlugin);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { speed: 0.6, revealDelay: 0.3, duration: 1.2, chars: 'alphanumeric', rightToLeft: false, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const GLYPHS = {
alphanumeric: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#@%&!',
binary: '01',
blocks: '▮▯░▒▓',
lowercase: 'abcdefghijklmnopqrstuvwxyz',
};
const CYCLE = 3; // seconds between status words
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.scr');
const title = document.querySelector('.scr__title');
const items = gsap.utils.toArray('.scr__item');
const labels = gsap.utils.toArray('.scr__label');
const status = document.querySelector('.scr__status');
const footnote = document.querySelector('.scr__footnote');
const words = status.dataset.words.split('|');
// Remember the real copy once, before any noise is written.
[title, ...labels].forEach((el) => { el.dataset.text = el.textContent; });
let listeners = [];
const unbind = () => {
listeners.forEach(([el, fn]) => { el.removeEventListener('pointerenter', fn); el.removeEventListener('focus', fn); });
listeners = [];
};
let ctx;
function build() {
ctx?.revert();
unbind();
const CHARS = GLYPHS[K('chars')] ?? GLYPHS.alphanumeric;
const SPEED = K('speed'); // how fast the random glyphs churn
const REVEAL_DELAY = K('revealDelay'); // seconds of pure noise before letters start resolving
const DURATION = K('duration'); // per-element decode time
const rightToLeft = !!K('rightToLeft');
section.style.setProperty('--knob-accent', K('accent'));
const scramble = (len) => Array.from({ length: len }, () => CHARS[Math.floor(Math.random() * CHARS.length)]).join('');
status.textContent = words[0];
ctx = gsap.context(() => {
if (reduced) { [title, ...labels].forEach((el) => { el.textContent = el.dataset.text; }); return; } // real text, no noise
// Pre-fill with noise so nothing readable flashes before the decode.
[title, ...labels].forEach((el) => { el.textContent = scramble(el.dataset.text.length); });
const decode = (el, duration, revealDelay, position) => tl.to(el, {
duration, scrambleText: { text: el.dataset.text, chars: CHARS, speed: SPEED, revealDelay, tweenLength: false, rightToLeft },
}, position);
const tl = gsap.timeline({ defaults: { ease: 'none' } });
tl.from('.scr__eyebrow', { opacity: 0, duration: 0.6 }, 0)
.from(items, { opacity: 0, duration: 0.5, stagger: 0.1 }, 0.5)
.from(footnote, { opacity: 0, duration: 0.6 }, 1.6);
decode(title, DURATION, REVEAL_DELAY, 0.2);
labels.forEach((label, i) => decode(label, DURATION, REVEAL_DELAY, 0.5 + i * 0.1));
// Status line: a new word decodes every 3 seconds (delayedCall lives on the global timeline, so it pauses with the host).
let w = 0;
const cycle = () => {
w = (w + 1) % words.length;
gsap.to(status, {
duration: 1, ease: 'none',
scrambleText: { text: words[w], chars: CHARS, speed: SPEED, revealDelay: 0.2, tweenLength: true, rightToLeft },
onComplete: () => gsap.delayedCall(CYCLE, cycle),
});
};
gsap.delayedCall(CYCLE, cycle);
gsap.to('.scr__dot', { opacity: 0.25, duration: 0.9, yoyo: true, repeat: -1, ease: 'sine.inOut' });
// Hover: re-encode just that label.
items.forEach((item) => {
const label = item.querySelector('.scr__label');
const onEnter = () => gsap.to(label, {
duration: 0.8, ease: 'none', overwrite: 'auto',
scrambleText: { text: label.dataset.text, chars: CHARS, speed: SPEED, revealDelay: 0.15, tweenLength: false, rightToLeft },
});
item.addEventListener('pointerenter', onEnter);
item.addEventListener('focus', onEnter);
listeners.push([item, onEnter]);
});
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => { unbind(); ctx?.revert(); });