Kinetic Typography Loop
A self-running motion poster: seven stacked rows of the same word, from hairline to black, shear and slide in alternating directions on a yoyo loop while a secondary word flips over in 3D every 1.6 seconds.
- Text
- gsap
- intermediate
- light perf
- mobile: full
- reduced-motion ✓
AI prompt
Recreate this effect with any AI tool
Paste into Claude, Cursor, v0, Lovable or Bolt. 1314 words of exact values — rewritten live as you tune the Playground.
### EFFECT: Kinetic Typography Loop
**What it does (visual):** A full-viewport dark poster with thin mono chrome in the corners (an eyebrow top-left, a tabular counter "000 / 100" top-right, a small italic word bottom-left, a signature line bottom-right). The middle is a stack of seven rows, each the same word repeated five times ("Kinetic Kinetic Kinetic…") in a light serif, growing in weight from a hairline 100 at the top to a black 900 at the bottom; even rows are italic and the centre row is the accent colour (#d4ff3a). On load the rows rise into place (60% of their height, 1.1s, staggered 60ms) while the whole stack fades in. Then the loop starts: odd rows slide left while even rows slide right (±4% of their width), every row shears up to ±12° and stretches vertically by 12%, each row 0.09s behind the one above so a ripple runs down the stack; after 2.4s the whole motion reverses (yoyo) and repeats forever with a heavy mechanical ease. The counter snaps from 000 to 100 and back in time with the stack. Independently, every 1.6s the small bottom-left word ("rhythm") tips backwards over its X axis, vanishes edge-on, and a new word ("weight", "tempo", "shear"…) swings in from the other side with a sharp snap.
**Tech stack:**
- GSAP 3.13+ with CustomEase (free in 3.13+).
- 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/CustomEase.min.js"></script>`
- npm: `import gsap from "gsap"; import { CustomEase } from "gsap/CustomEase"; gsap.registerPlugin(CustomEase);`
- Fonts: a variable serif with an italic axis file (demo uses Fraunces 100–900 + Fraunces Italic) and a mono for chrome.
**HTML structure:**
```html
<section class="kt" aria-label="Kinetic typography poster">
<header class="kt__chrome kt__chrome--top">
<p class="kt__eyebrow">Text · Kinetic loop</p>
<p class="kt__counter" aria-hidden="true"><span class="kt__counter-value">000</span> / 100</p>
</header>
<div class="kt__stack" aria-label="Kinetic">
<div class="kt__row" style="--w: 100"><span class="kt__line">Kinetic Kinetic Kinetic Kinetic Kinetic</span></div>
<div class="kt__row kt__row--italic" style="--w: 230"><span class="kt__line">…</span></div>
<div class="kt__row" style="--w: 360"><span class="kt__line">…</span></div>
<div class="kt__row kt__row--italic kt__row--accent" style="--w: 500"><span class="kt__line">…</span></div>
<div class="kt__row" style="--w: 640"><span class="kt__line">…</span></div>
<div class="kt__row kt__row--italic" style="--w: 780"><span class="kt__line">…</span></div>
<div class="kt__row" style="--w: 900"><span class="kt__line">…</span></div>
</div>
<footer class="kt__chrome kt__chrome--bottom">
<p class="kt__flip" aria-live="polite"><span class="kt__flip-label">a study in</span><span class="kt__flip-stage"><span class="kt__flip-word">rhythm</span></span></p>
<p class="kt__sign">Motion/Lab · Poster 07 · 2026</p>
</footer>
</section>
```
**CSS requirements:**
- `.kt`: `position: relative; min-height: 100vh; display: grid; grid-template-rows: auto 1fr auto; padding: 2.5rem 6vw; overflow: hidden; background: var(--bg); color: var(--text);`
- `.kt__chrome`: `display: flex; justify-content: space-between; align-items: baseline;` mono 11px uppercase, `letter-spacing: .14em`, muted. `.kt__counter { font-variant-numeric: tabular-nums }`, `.kt__counter-value { color: var(--accent, #d4ff3a) }`.
- `.kt__stack`: `display: grid; align-content: center; justify-items: center; padding: 3vh 0;`
- `.kt__row`: `width: 100vw; margin-inline: -6vw; overflow: hidden; display: flex; justify-content: center;` (full-bleed clip so the sliding lines never cause horizontal scroll).
- `.kt__line`: `display: inline-block; white-space: nowrap; font-family: var(--font-display); font-weight: var(--w); font-variation-settings: 'opsz' 144, 'wght' var(--w); font-size: clamp(2.2rem, 8.6vh, 9.4rem); line-height: .86; letter-spacing: -0.05em; transform-origin: 50% 50%; will-change: transform;` — sizing by `vh` guarantees seven rows fit any viewport height.
- `.kt__row--italic .kt__line { font-style: italic }`, `.kt__row--accent .kt__line { color: var(--accent, #d4ff3a) }`.
- `.kt__flip-stage`: `display: inline-block; perspective: 700px; min-width: 6ch;` `.kt__flip-word`: display serif italic 300, `font-size: clamp(1.6rem, 3.2vw, 2.6rem); letter-spacing: -0.03em; line-height: 1; transform-origin: 50% 50%; backface-visibility: hidden; will-change: transform;`
- Under 768px: `padding: 1.5rem 5vw`, rows `margin-inline: -5vw`, `.kt__line { font-size: clamp(2rem, 7.2vh, 5rem) }`, hide `.kt__sign`.
**Animation spec (exact values):**
1. Eases: `CustomEase.create('ktSwing', 'M0,0 C0.6,0 0.2,1 1,1')` (heavy in, long settle) and `CustomEase.create('ktSnap', 'M0,0 C0.2,0 0.05,1 1,1')` (near-instant start, long tail).
2. Constants: `ROW_DURATION = 2.4`, `ROW_OFFSET = 0.09`, `FLIP_PERIOD = 1.6`, `travel = 4` (xPercent; ×0.75 under 768px), `shear = 12` (deg; ×⅔ under 768px).
3. Stack timeline: `stack = gsap.timeline({ repeat: -1, yoyo: true, repeatDelay: 0.15, defaults: { duration: 2.4, ease: 'ktSwing' } })`. For each `.kt__line` at index `i`, `dir = i % 2 === 0 ? -1 : 1`:
`stack.fromTo(line, { xPercent: dir * 4, skewX: dir * 12, scaleY: 1 }, { xPercent: -dir * 4, skewX: -dir * 12, scaleY: 1.12 }, i * 0.09)` — targets each line; `xPercent` `±4 → ∓4`, `skewX` `±12 → ∓12`, `scaleY` `1 → 1.12`; position `i * 0.09`.
Extra layers on the same timeline: `stack.fromTo(lines[3], { scaleX: 1 }, { scaleX: 1.06, ease: 'power2.inOut' }, 0.2)`; `stack.fromTo([lines[0], lines[6]], { opacity: 0.45 }, { opacity: 1, ease: 'power2.inOut' }, 0)`.
4. Counter on the stack timeline so it reverses with the yoyo: `counter = { value: 0 }`; `stack.to(counter, { value: 100, duration: 2.4 + 6 * 0.09, ease: 'none', snap: { value: 1 }, onUpdate: () => counterValue.textContent = String(Math.round(counter.value)).padStart(3, '0') }, 0)`.
5. Flip loop: `gsap.set('.kt__flip-word', { transformPerspective: 700 })`; `flip = gsap.timeline({ repeat: -1, repeatDelay: 1.6 - 1.1 })`:
- `.to(word, { rotateX: -90, yPercent: -40, opacity: 0, duration: 0.5, ease: 'power2.in' })`
- `.call(() => { index = (index + 1) % WORDS.length; word.textContent = WORDS[index]; })` with `WORDS = ['rhythm','weight','tempo','shear','balance','contrast']`
- `.fromTo(word, { rotateX: 90, yPercent: 40, opacity: 0 }, { rotateX: 0, yPercent: 0, opacity: 1, duration: 0.6, ease: 'ktSnap' })`
Total period 0.5 + 0.6 + repeatDelay = 1.6s.
6. Entrance (plays once, no property overlap with the loops): `gsap.from(lines, { yPercent: 60, duration: 1.1, ease: 'power4.out', stagger: 0.06 })`, `gsap.from('.kt__stack', { opacity: 0, duration: 0.9, ease: 'power2.out' })`, `gsap.from(['.kt__chrome--top', '.kt__chrome--bottom'], { opacity: 0, y: 12, duration: 0.8, ease: 'power2.out', delay: 0.4, stagger: 0.1 })`.
**Interaction:** None — the piece is autonomous. It is suitable as a hero or a section divider that reads as a living poster. GSAP's global timeline pause (tab hidden / off-screen) freezes both loops; they resume in phase.
**Responsive behavior:** Row size is driven by viewport height (`8.6vh`, 7.2vh on phones) so all seven rows always fit between the chrome bars. Under 768px (`gsap.matchMedia`) travel drops to 0.75 × 4% and shear to ⅔ × 12° so the clipped rows do not look chopped on a narrow screen; the signature line is hidden. Each row is a full-bleed `overflow: hidden` strip so the sliding text never widens the page.
**Accessibility:** With `prefers-reduced-motion: reduce` nothing loops: rows are set to a resting pose (half the travel and shear: `xPercent ±4/2`, `skewX ±12/2`, alternating), the counter reads a fixed "050" and the flip word stays on its first value. The stack is one labelled region (`aria-label="Kinetic"`) so assistive tech is not read 35 repeated words; the counter is `aria-hidden`; the flipping word is in an `aria-live="polite"` container so screen readers can follow if they want to. All text remains real, selectable text.
**Performance rules:** Only `transform` (`xPercent`, `skewX`, `scaleX/Y`, `rotateX`, `yPercent`) and `opacity` are animated; weights are static per row via `font-variation-settings` (animating `wght` would re-shape glyphs every frame). `will-change: transform` sits on `.kt__line` and `.kt__flip-word` only. Everything runs inside `gsap.context()` and is reverted on unmount (`ctx.revert()` kills both loops and restores the DOM text). The two loops are infinite GSAP timelines, so pausing/resuming is handled by the global timeline — no custom rAF.
**Customization knobs:**
- `ROW_DURATION` (2.4s) / `ROW_OFFSET` (0.09s) — tempo and ripple depth.
- `travel` (4) and `shear` (12) — how violent the stack is. 2/6 is elegant, 8/20 is punk.
- `FLIP_PERIOD` (1.6s) and `WORDS[]` — the beat and the vocabulary.
- Accent colour (`#d4ff3a`) — the centre row and the counter.
- `--w` per row — redistribute weights (e.g. 100/900 alternating for a stripe look).
- The two CustomEase strings — swap `ktSwing` for `'power2.inOut'` for a softer, less mechanical feel.
**Acceptance criteria:**
- [ ] Seven rows, weights ascending 100 → 900, even rows italic, centre row #d4ff3a, all visible without scrolling at 1200×750 and 375×667.
- [ ] Rows ripple top-to-bottom with a 0.09s offset and reverse every 2.4s; the motion never stops.
- [ ] Counter runs 000 → 100 → 000 in sync with the stack.
- [ ] The small word flips every 1.6s and the text swap is invisible (happens edge-on).
- [ ] Reduced motion shows the static poster with the counter at 050; no console errors. Add this effect to my existing site/section: **Kinetic Typography Loop**. Use my hero word: render it in a `.kt__stack` of seven `.kt__row > .kt__line` strips (word repeated five times, `white-space: nowrap`, full-bleed `overflow: hidden`), each strip with `--w` weights 100, 230, 360, 500, 640, 780, 900, even rows italic, row four in `var(--accent, #d4ff3a)`. With GSAP + CustomEase, create `ktSwing = 'M0,0 C0.6,0 0.2,1 1,1'` and `ktSnap = 'M0,0 C0.2,0 0.05,1 1,1'`, then a `gsap.timeline({ repeat: -1, yoyo: true, repeatDelay: 0.15 })` where row `i` goes `fromTo({ xPercent: dir*4, skewX: dir*12, scaleY: 1 }, { xPercent: -dir*4, skewX: -dir*12, scaleY: 1.12, duration: 2.4, ease: 'ktSwing' })` at position `i * 0.09` (`dir = i % 2 ? 1 : -1`). Add a tabular counter snapping 000 → 100 on the same timeline. Add a small flipping word: `rotateX 0 → -90` (0.5s power2.in, opacity 0), swap text in a `.call()`, then `fromTo rotateX 90 → 0` (0.6s `ktSnap`), repeating every 1.6s with `transformPerspective: 700`. Under `prefers-reduced-motion` show the static poster. Wrap everything in `gsap.context()` and revert on unmount. Keep my fonts and colours; only the word changes. ### EFFECT (React / Next.js): Kinetic Typography Loop
Build **Kinetic Typography Loop** 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/KineticTypographyLoop.tsx`, first line `"use client";` (GSAP touches the DOM).
- Imports:
```tsx
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { CustomEase } from "gsap/CustomEase";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(useGSAP, CustomEase);
export default function KineticTypographyLoop() {
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="kt">
{/* 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 (`KineticTypographyLoop.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: Kinetic Typography Loop
**What it does (visual):** A full-viewport dark poster with thin mono chrome in the corners (an eyebrow top-left, a tabular counter "000 / 100" top-right, a small italic word bottom-left, a signature line bottom-right). The middle is a stack of seven rows, each the same word repeated five times ("Kinetic Kinetic Kinetic…") in a light serif, growing in weight from a hairline 100 at the top to a black 900 at the bottom; even rows are italic and the centre row is the accent colour (#d4ff3a). On load the rows rise into place (60% of their height, 1.1s, staggered 60ms) while the whole stack fades in. Then the loop starts: odd rows slide left while even rows slide right (±4% of their width), every row shears up to ±12° and stretches vertically by 12%, each row 0.09s behind the one above so a ripple runs down the stack; after 2.4s the whole motion reverses (yoyo) and repeats forever with a heavy mechanical ease. The counter snaps from 000 to 100 and back in time with the stack. Independently, every 1.6s the small bottom-left word ("rhythm") tips backwards over its X axis, vanishes edge-on, and a new word ("weight", "tempo", "shear"…) swings in from the other side with a sharp snap.
**Tech stack:**
- GSAP 3.13+ with CustomEase (free in 3.13+).
- 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/CustomEase.min.js"></script>`
- npm: `import gsap from "gsap"; import { CustomEase } from "gsap/CustomEase"; gsap.registerPlugin(CustomEase);`
- Fonts: a variable serif with an italic axis file (demo uses Fraunces 100–900 + Fraunces Italic) and a mono for chrome.
**HTML structure:**
```html
<section class="kt" aria-label="Kinetic typography poster">
<header class="kt__chrome kt__chrome--top">
<p class="kt__eyebrow">Text · Kinetic loop</p>
<p class="kt__counter" aria-hidden="true"><span class="kt__counter-value">000</span> / 100</p>
</header>
<div class="kt__stack" aria-label="Kinetic">
<div class="kt__row" style="--w: 100"><span class="kt__line">Kinetic Kinetic Kinetic Kinetic Kinetic</span></div>
<div class="kt__row kt__row--italic" style="--w: 230"><span class="kt__line">…</span></div>
<div class="kt__row" style="--w: 360"><span class="kt__line">…</span></div>
<div class="kt__row kt__row--italic kt__row--accent" style="--w: 500"><span class="kt__line">…</span></div>
<div class="kt__row" style="--w: 640"><span class="kt__line">…</span></div>
<div class="kt__row kt__row--italic" style="--w: 780"><span class="kt__line">…</span></div>
<div class="kt__row" style="--w: 900"><span class="kt__line">…</span></div>
</div>
<footer class="kt__chrome kt__chrome--bottom">
<p class="kt__flip" aria-live="polite"><span class="kt__flip-label">a study in</span><span class="kt__flip-stage"><span class="kt__flip-word">rhythm</span></span></p>
<p class="kt__sign">Motion/Lab · Poster 07 · 2026</p>
</footer>
</section>
```
**CSS requirements:**
- `.kt`: `position: relative; min-height: 100vh; display: grid; grid-template-rows: auto 1fr auto; padding: 2.5rem 6vw; overflow: hidden; background: var(--bg); color: var(--text);`
- `.kt__chrome`: `display: flex; justify-content: space-between; align-items: baseline;` mono 11px uppercase, `letter-spacing: .14em`, muted. `.kt__counter { font-variant-numeric: tabular-nums }`, `.kt__counter-value { color: var(--accent, #d4ff3a) }`.
- `.kt__stack`: `display: grid; align-content: center; justify-items: center; padding: 3vh 0;`
- `.kt__row`: `width: 100vw; margin-inline: -6vw; overflow: hidden; display: flex; justify-content: center;` (full-bleed clip so the sliding lines never cause horizontal scroll).
- `.kt__line`: `display: inline-block; white-space: nowrap; font-family: var(--font-display); font-weight: var(--w); font-variation-settings: 'opsz' 144, 'wght' var(--w); font-size: clamp(2.2rem, 8.6vh, 9.4rem); line-height: .86; letter-spacing: -0.05em; transform-origin: 50% 50%; will-change: transform;` — sizing by `vh` guarantees seven rows fit any viewport height.
- `.kt__row--italic .kt__line { font-style: italic }`, `.kt__row--accent .kt__line { color: var(--accent, #d4ff3a) }`.
- `.kt__flip-stage`: `display: inline-block; perspective: 700px; min-width: 6ch;` `.kt__flip-word`: display serif italic 300, `font-size: clamp(1.6rem, 3.2vw, 2.6rem); letter-spacing: -0.03em; line-height: 1; transform-origin: 50% 50%; backface-visibility: hidden; will-change: transform;`
- Under 768px: `padding: 1.5rem 5vw`, rows `margin-inline: -5vw`, `.kt__line { font-size: clamp(2rem, 7.2vh, 5rem) }`, hide `.kt__sign`.
**Animation spec (exact values):**
1. Eases: `CustomEase.create('ktSwing', 'M0,0 C0.6,0 0.2,1 1,1')` (heavy in, long settle) and `CustomEase.create('ktSnap', 'M0,0 C0.2,0 0.05,1 1,1')` (near-instant start, long tail).
2. Constants: `ROW_DURATION = 2.4`, `ROW_OFFSET = 0.09`, `FLIP_PERIOD = 1.6`, `travel = 4` (xPercent; ×0.75 under 768px), `shear = 12` (deg; ×⅔ under 768px).
3. Stack timeline: `stack = gsap.timeline({ repeat: -1, yoyo: true, repeatDelay: 0.15, defaults: { duration: 2.4, ease: 'ktSwing' } })`. For each `.kt__line` at index `i`, `dir = i % 2 === 0 ? -1 : 1`:
`stack.fromTo(line, { xPercent: dir * 4, skewX: dir * 12, scaleY: 1 }, { xPercent: -dir * 4, skewX: -dir * 12, scaleY: 1.12 }, i * 0.09)` — targets each line; `xPercent` `±4 → ∓4`, `skewX` `±12 → ∓12`, `scaleY` `1 → 1.12`; position `i * 0.09`.
Extra layers on the same timeline: `stack.fromTo(lines[3], { scaleX: 1 }, { scaleX: 1.06, ease: 'power2.inOut' }, 0.2)`; `stack.fromTo([lines[0], lines[6]], { opacity: 0.45 }, { opacity: 1, ease: 'power2.inOut' }, 0)`.
4. Counter on the stack timeline so it reverses with the yoyo: `counter = { value: 0 }`; `stack.to(counter, { value: 100, duration: 2.4 + 6 * 0.09, ease: 'none', snap: { value: 1 }, onUpdate: () => counterValue.textContent = String(Math.round(counter.value)).padStart(3, '0') }, 0)`.
5. Flip loop: `gsap.set('.kt__flip-word', { transformPerspective: 700 })`; `flip = gsap.timeline({ repeat: -1, repeatDelay: 1.6 - 1.1 })`:
- `.to(word, { rotateX: -90, yPercent: -40, opacity: 0, duration: 0.5, ease: 'power2.in' })`
- `.call(() => { index = (index + 1) % WORDS.length; word.textContent = WORDS[index]; })` with `WORDS = ['rhythm','weight','tempo','shear','balance','contrast']`
- `.fromTo(word, { rotateX: 90, yPercent: 40, opacity: 0 }, { rotateX: 0, yPercent: 0, opacity: 1, duration: 0.6, ease: 'ktSnap' })`
Total period 0.5 + 0.6 + repeatDelay = 1.6s.
6. Entrance (plays once, no property overlap with the loops): `gsap.from(lines, { yPercent: 60, duration: 1.1, ease: 'power4.out', stagger: 0.06 })`, `gsap.from('.kt__stack', { opacity: 0, duration: 0.9, ease: 'power2.out' })`, `gsap.from(['.kt__chrome--top', '.kt__chrome--bottom'], { opacity: 0, y: 12, duration: 0.8, ease: 'power2.out', delay: 0.4, stagger: 0.1 })`.
**Interaction:** None — the piece is autonomous. It is suitable as a hero or a section divider that reads as a living poster. GSAP's global timeline pause (tab hidden / off-screen) freezes both loops; they resume in phase.
**Responsive behavior:** Row size is driven by viewport height (`8.6vh`, 7.2vh on phones) so all seven rows always fit between the chrome bars. Under 768px (`gsap.matchMedia`) travel drops to 0.75 × 4% and shear to ⅔ × 12° so the clipped rows do not look chopped on a narrow screen; the signature line is hidden. Each row is a full-bleed `overflow: hidden` strip so the sliding text never widens the page.
**Accessibility:** With `prefers-reduced-motion: reduce` nothing loops: rows are set to a resting pose (half the travel and shear: `xPercent ±4/2`, `skewX ±12/2`, alternating), the counter reads a fixed "050" and the flip word stays on its first value. The stack is one labelled region (`aria-label="Kinetic"`) so assistive tech is not read 35 repeated words; the counter is `aria-hidden`; the flipping word is in an `aria-live="polite"` container so screen readers can follow if they want to. All text remains real, selectable text.
**Performance rules:** Only `transform` (`xPercent`, `skewX`, `scaleX/Y`, `rotateX`, `yPercent`) and `opacity` are animated; weights are static per row via `font-variation-settings` (animating `wght` would re-shape glyphs every frame). `will-change: transform` sits on `.kt__line` and `.kt__flip-word` only. Everything runs inside `gsap.context()` and is reverted on unmount (`ctx.revert()` kills both loops and restores the DOM text). The two loops are infinite GSAP timelines, so pausing/resuming is handled by the global timeline — no custom rAF.
**Customization knobs:**
- `ROW_DURATION` (2.4s) / `ROW_OFFSET` (0.09s) — tempo and ripple depth.
- `travel` (4) and `shear` (12) — how violent the stack is. 2/6 is elegant, 8/20 is punk.
- `FLIP_PERIOD` (1.6s) and `WORDS[]` — the beat and the vocabulary.
- Accent colour (`#d4ff3a`) — the centre row and the counter.
- `--w` per row — redistribute weights (e.g. 100/900 alternating for a stripe look).
- The two CustomEase strings — swap `ktSwing` for `'power2.inOut'` for a softer, less mechanical feel.
**Acceptance criteria:**
- [ ] Seven rows, weights ascending 100 → 900, even rows italic, centre row #d4ff3a, all visible without scrolling at 1200×750 and 375×667.
- [ ] Rows ripple top-to-bottom with a 0.09s offset and reverse every 2.4s; the motion never stops.
- [ ] Counter runs 000 → 100 → 000 in sync with the stack.
- [ ] The small word flips every 1.6s and the text swap is invisible (happens edge-on).
- [ ] Reduced motion shows the static poster with the counter at 050; no console errors.
---
**Reference implementation (vanilla, converts 1:1 — same class names, same values):**
```html
<section class="kt" aria-label="Kinetic typography poster">
<header class="kt__chrome kt__chrome--top">
<p class="kt__eyebrow">Text · Kinetic loop</p>
<p class="kt__counter" aria-hidden="true"><span class="kt__counter-value">000</span> / 100</p>
</header>
<div class="kt__stack" aria-label="Kinetic">
<div class="kt__row" style="--w: 100"><span class="kt__line">Kinetic Kinetic Kinetic Kinetic Kinetic</span></div>
<div class="kt__row kt__row--italic" style="--w: 230"><span class="kt__line">Kinetic Kinetic Kinetic Kinetic Kinetic</span></div>
<div class="kt__row" style="--w: 360"><span class="kt__line">Kinetic Kinetic Kinetic Kinetic Kinetic</span></div>
<div class="kt__row kt__row--italic kt__row--accent" style="--w: 500"><span class="kt__line">Kinetic Kinetic Kinetic Kinetic Kinetic</span></div>
<div class="kt__row" style="--w: 640"><span class="kt__line">Kinetic Kinetic Kinetic Kinetic Kinetic</span></div>
<div class="kt__row kt__row--italic" style="--w: 780"><span class="kt__line">Kinetic Kinetic Kinetic Kinetic Kinetic</span></div>
<div class="kt__row" style="--w: 900"><span class="kt__line">Kinetic Kinetic Kinetic Kinetic Kinetic</span></div>
</div>
<footer class="kt__chrome kt__chrome--bottom">
<p class="kt__flip" aria-live="polite">
<span class="kt__flip-label">a study in</span>
<span class="kt__flip-stage"><span class="kt__flip-word">rhythm</span></span>
</p>
<p class="kt__sign">Motion/Lab · Poster 07 · 2026</p>
</footer>
</section>
```
```css
.kt { position: relative; min-height: 100vh; display: grid; grid-template-rows: auto 1fr auto; padding: 2.5rem 6vw; overflow: hidden; background: var(--bg); color: var(--text); }
.kt__chrome { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); position: relative; z-index: 2; }
.kt__counter { font-variant-numeric: tabular-nums; }
.kt__counter-value { color: var(--knob-accent, var(--accent)); }
.kt__stack { display: grid; align-content: center; justify-items: center; padding: 3vh 0; }
.kt__row { width: 100vw; margin-inline: -6vw; overflow: hidden; display: flex; justify-content: center; }
.kt__line {
display: inline-block; white-space: nowrap;
font-family: var(--font-display); font-weight: var(--w, 400); font-size: clamp(2.2rem, 8.6vh, 9.4rem); line-height: .86; letter-spacing: -0.05em;
font-variation-settings: 'opsz' 144, 'wght' var(--w, 400); transform-origin: 50% 50%; will-change: transform;
}
.kt__row--italic .kt__line { font-style: italic; }
.kt__row--accent .kt__line { color: var(--knob-accent, var(--accent)); }
.kt__flip { display: flex; align-items: baseline; gap: .75em; text-transform: none; letter-spacing: 0; }
.kt__flip-label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); }
.kt__flip-stage { display: inline-block; perspective: 700px; min-width: 6ch; }
.kt__flip-word { display: inline-block; font-family: var(--font-display); font-style: italic; font-weight: 300; font-size: clamp(1.6rem, 3.2vw, 2.6rem); letter-spacing: -0.03em; line-height: 1; color: var(--text); transform-origin: 50% 50%; backface-visibility: hidden; will-change: transform; }
.kt__sign { text-align: right; }
@media (max-width: 767px) {
.kt { padding: 1.5rem 5vw; }
.kt__row { margin-inline: -5vw; }
.kt__line { font-size: clamp(2rem, 7.2vh, 5rem); }
.kt__sign { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.kt__line, .kt__flip-word { will-change: auto; }
}
```
```js
// Kinetic Typography Loop — a self-running type poster: yoyo stack, 3D flipping word, snapping counter.
gsap.registerPlugin(CustomEase);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { rowDuration: 2.4, rowOffset: 0.09, travel: 4, shear: 12, flipPeriod: 1.6, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const WORDS = ['rhythm', 'weight', 'tempo', 'shear', 'balance', 'contrast'];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.kt');
const lines = gsap.utils.toArray('.kt__line');
const flipWord = document.querySelector('.kt__flip-word');
const counterValue = document.querySelector('.kt__counter-value');
const firstWord = flipWord.textContent;
let ctx;
function build() {
ctx?.revert();
flipWord.textContent = firstWord;
const accent = String(K('accent')).toLowerCase();
if (accent !== DEFAULTS.accent) section.style.setProperty('--knob-accent', accent); else section.style.removeProperty('--knob-accent');
const ROW_DURATION = K('rowDuration'); // one half-cycle of the stack (the timeline yoyos back)
const ROW_OFFSET = K('rowOffset'); // stagger between rows
const FLIP_PERIOD = Math.max(1.2, K('flipPeriod')); // seconds between word flips (0.5 out + 0.6 in + delay)
ctx = gsap.context(() => {
// Signature eases: a heavy mechanical swing for the stack, a hard snap for the flip.
CustomEase.create('ktSwing', 'M0,0 C0.6,0 0.2,1 1,1');
CustomEase.create('ktSnap', 'M0,0 C0.2,0 0.05,1 1,1');
if (reduced) {
// Final-state poster: rows resting at a gentle alternating shear, counter parked, word static.
const t = K('travel') / 2, sh = K('shear') / 2;
lines.forEach((line, i) => gsap.set(line, { xPercent: (i % 2 ? t : -t), skewX: (i % 2 ? -sh : sh) }));
counterValue.textContent = '050';
return;
}
const mm = gsap.matchMedia();
mm.add({ desktop: '(min-width: 768px)', mobile: '(max-width: 767px)' }, (mmCtx) => {
const { mobile } = mmCtx.conditions;
const travel = mobile ? K('travel') * 0.75 : K('travel'); // xPercent amplitude
const shear = mobile ? K('shear') * 2 / 3 : K('shear'); // skewX amplitude in degrees
// 1. The stack — one yoyo timeline, every row written as fromTo so the loop is deterministic.
const stack = gsap.timeline({ repeat: -1, yoyo: true, repeatDelay: 0.15, defaults: { duration: ROW_DURATION, ease: 'ktSwing' } });
lines.forEach((line, i) => {
const dir = i % 2 === 0 ? -1 : 1;
stack.fromTo(line,
{ xPercent: dir * travel, skewX: dir * shear, scaleY: 1 },
{ xPercent: -dir * travel, skewX: -dir * shear, scaleY: 1.12 },
i * ROW_OFFSET,
);
});
// Centre (accent) row also breathes in size; outer rows fade in and out.
stack.fromTo(lines[3], { scaleX: 1 }, { scaleX: 1.06, ease: 'power2.inOut' }, 0.2);
stack.fromTo([lines[0], lines[6]], { opacity: 0.45 }, { opacity: 1, ease: 'power2.inOut' }, 0);
// Counter 000 → 100 rides the same timeline, so it runs back down on the yoyo.
const counter = { value: 0 };
stack.to(counter, {
value: 100, duration: ROW_DURATION + 6 * ROW_OFFSET, ease: 'none', snap: { value: 1 },
onUpdate: () => { counterValue.textContent = String(Math.round(counter.value)).padStart(3, '0'); },
}, 0);
// 2. The flipping word — rotateX out, swap at edge-on, rotateX in. Period = 0.5 + 0.6 + repeatDelay.
let index = 0;
gsap.set(flipWord, { transformPerspective: 700 });
const flip = gsap.timeline({ repeat: -1, repeatDelay: FLIP_PERIOD - 1.1 });
flip.to(flipWord, { rotateX: -90, yPercent: -40, opacity: 0, duration: 0.5, ease: 'power2.in' })
.call(() => { index = (index + 1) % WORDS.length; flipWord.textContent = WORDS[index]; })
.fromTo(flipWord, { rotateX: 90, yPercent: 40, opacity: 0 }, { rotateX: 0, yPercent: 0, opacity: 1, duration: 0.6, ease: 'ktSnap' });
// 3. Entrance — the stack rises in once, then the loops take over.
gsap.from(lines, { yPercent: 60, duration: 1.1, ease: 'power4.out', stagger: 0.06 });
gsap.from('.kt__stack', { opacity: 0, duration: 0.9, ease: 'power2.out' });
gsap.from(['.kt__chrome--top', '.kt__chrome--bottom'], { opacity: 0, y: 12, duration: 0.8, ease: 'power2.out', delay: 0.4, stagger: 0.1 });
return () => { stack.kill(); flip.kill(); };
});
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());
```
### EFFECT (single HTML file): Kinetic Typography Loop
Produce **one self-contained `index.html`** (inline `<style>` and `<script type="module">`, no build step) that implements **Kinetic Typography Loop** 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>Kinetic Typography Loop</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/CustomEase.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: Kinetic Typography Loop
**What it does (visual):** A full-viewport dark poster with thin mono chrome in the corners (an eyebrow top-left, a tabular counter "000 / 100" top-right, a small italic word bottom-left, a signature line bottom-right). The middle is a stack of seven rows, each the same word repeated five times ("Kinetic Kinetic Kinetic…") in a light serif, growing in weight from a hairline 100 at the top to a black 900 at the bottom; even rows are italic and the centre row is the accent colour (#d4ff3a). On load the rows rise into place (60% of their height, 1.1s, staggered 60ms) while the whole stack fades in. Then the loop starts: odd rows slide left while even rows slide right (±4% of their width), every row shears up to ±12° and stretches vertically by 12%, each row 0.09s behind the one above so a ripple runs down the stack; after 2.4s the whole motion reverses (yoyo) and repeats forever with a heavy mechanical ease. The counter snaps from 000 to 100 and back in time with the stack. Independently, every 1.6s the small bottom-left word ("rhythm") tips backwards over its X axis, vanishes edge-on, and a new word ("weight", "tempo", "shear"…) swings in from the other side with a sharp snap.
**Tech stack:**
- GSAP 3.13+ with CustomEase (free in 3.13+).
- 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/CustomEase.min.js"></script>`
- npm: `import gsap from "gsap"; import { CustomEase } from "gsap/CustomEase"; gsap.registerPlugin(CustomEase);`
- Fonts: a variable serif with an italic axis file (demo uses Fraunces 100–900 + Fraunces Italic) and a mono for chrome.
**HTML structure:**
```html
<section class="kt" aria-label="Kinetic typography poster">
<header class="kt__chrome kt__chrome--top">
<p class="kt__eyebrow">Text · Kinetic loop</p>
<p class="kt__counter" aria-hidden="true"><span class="kt__counter-value">000</span> / 100</p>
</header>
<div class="kt__stack" aria-label="Kinetic">
<div class="kt__row" style="--w: 100"><span class="kt__line">Kinetic Kinetic Kinetic Kinetic Kinetic</span></div>
<div class="kt__row kt__row--italic" style="--w: 230"><span class="kt__line">…</span></div>
<div class="kt__row" style="--w: 360"><span class="kt__line">…</span></div>
<div class="kt__row kt__row--italic kt__row--accent" style="--w: 500"><span class="kt__line">…</span></div>
<div class="kt__row" style="--w: 640"><span class="kt__line">…</span></div>
<div class="kt__row kt__row--italic" style="--w: 780"><span class="kt__line">…</span></div>
<div class="kt__row" style="--w: 900"><span class="kt__line">…</span></div>
</div>
<footer class="kt__chrome kt__chrome--bottom">
<p class="kt__flip" aria-live="polite"><span class="kt__flip-label">a study in</span><span class="kt__flip-stage"><span class="kt__flip-word">rhythm</span></span></p>
<p class="kt__sign">Motion/Lab · Poster 07 · 2026</p>
</footer>
</section>
```
**CSS requirements:**
- `.kt`: `position: relative; min-height: 100vh; display: grid; grid-template-rows: auto 1fr auto; padding: 2.5rem 6vw; overflow: hidden; background: var(--bg); color: var(--text);`
- `.kt__chrome`: `display: flex; justify-content: space-between; align-items: baseline;` mono 11px uppercase, `letter-spacing: .14em`, muted. `.kt__counter { font-variant-numeric: tabular-nums }`, `.kt__counter-value { color: var(--accent, #d4ff3a) }`.
- `.kt__stack`: `display: grid; align-content: center; justify-items: center; padding: 3vh 0;`
- `.kt__row`: `width: 100vw; margin-inline: -6vw; overflow: hidden; display: flex; justify-content: center;` (full-bleed clip so the sliding lines never cause horizontal scroll).
- `.kt__line`: `display: inline-block; white-space: nowrap; font-family: var(--font-display); font-weight: var(--w); font-variation-settings: 'opsz' 144, 'wght' var(--w); font-size: clamp(2.2rem, 8.6vh, 9.4rem); line-height: .86; letter-spacing: -0.05em; transform-origin: 50% 50%; will-change: transform;` — sizing by `vh` guarantees seven rows fit any viewport height.
- `.kt__row--italic .kt__line { font-style: italic }`, `.kt__row--accent .kt__line { color: var(--accent, #d4ff3a) }`.
- `.kt__flip-stage`: `display: inline-block; perspective: 700px; min-width: 6ch;` `.kt__flip-word`: display serif italic 300, `font-size: clamp(1.6rem, 3.2vw, 2.6rem); letter-spacing: -0.03em; line-height: 1; transform-origin: 50% 50%; backface-visibility: hidden; will-change: transform;`
- Under 768px: `padding: 1.5rem 5vw`, rows `margin-inline: -5vw`, `.kt__line { font-size: clamp(2rem, 7.2vh, 5rem) }`, hide `.kt__sign`.
**Animation spec (exact values):**
1. Eases: `CustomEase.create('ktSwing', 'M0,0 C0.6,0 0.2,1 1,1')` (heavy in, long settle) and `CustomEase.create('ktSnap', 'M0,0 C0.2,0 0.05,1 1,1')` (near-instant start, long tail).
2. Constants: `ROW_DURATION = 2.4`, `ROW_OFFSET = 0.09`, `FLIP_PERIOD = 1.6`, `travel = 4` (xPercent; ×0.75 under 768px), `shear = 12` (deg; ×⅔ under 768px).
3. Stack timeline: `stack = gsap.timeline({ repeat: -1, yoyo: true, repeatDelay: 0.15, defaults: { duration: 2.4, ease: 'ktSwing' } })`. For each `.kt__line` at index `i`, `dir = i % 2 === 0 ? -1 : 1`:
`stack.fromTo(line, { xPercent: dir * 4, skewX: dir * 12, scaleY: 1 }, { xPercent: -dir * 4, skewX: -dir * 12, scaleY: 1.12 }, i * 0.09)` — targets each line; `xPercent` `±4 → ∓4`, `skewX` `±12 → ∓12`, `scaleY` `1 → 1.12`; position `i * 0.09`.
Extra layers on the same timeline: `stack.fromTo(lines[3], { scaleX: 1 }, { scaleX: 1.06, ease: 'power2.inOut' }, 0.2)`; `stack.fromTo([lines[0], lines[6]], { opacity: 0.45 }, { opacity: 1, ease: 'power2.inOut' }, 0)`.
4. Counter on the stack timeline so it reverses with the yoyo: `counter = { value: 0 }`; `stack.to(counter, { value: 100, duration: 2.4 + 6 * 0.09, ease: 'none', snap: { value: 1 }, onUpdate: () => counterValue.textContent = String(Math.round(counter.value)).padStart(3, '0') }, 0)`.
5. Flip loop: `gsap.set('.kt__flip-word', { transformPerspective: 700 })`; `flip = gsap.timeline({ repeat: -1, repeatDelay: 1.6 - 1.1 })`:
- `.to(word, { rotateX: -90, yPercent: -40, opacity: 0, duration: 0.5, ease: 'power2.in' })`
- `.call(() => { index = (index + 1) % WORDS.length; word.textContent = WORDS[index]; })` with `WORDS = ['rhythm','weight','tempo','shear','balance','contrast']`
- `.fromTo(word, { rotateX: 90, yPercent: 40, opacity: 0 }, { rotateX: 0, yPercent: 0, opacity: 1, duration: 0.6, ease: 'ktSnap' })`
Total period 0.5 + 0.6 + repeatDelay = 1.6s.
6. Entrance (plays once, no property overlap with the loops): `gsap.from(lines, { yPercent: 60, duration: 1.1, ease: 'power4.out', stagger: 0.06 })`, `gsap.from('.kt__stack', { opacity: 0, duration: 0.9, ease: 'power2.out' })`, `gsap.from(['.kt__chrome--top', '.kt__chrome--bottom'], { opacity: 0, y: 12, duration: 0.8, ease: 'power2.out', delay: 0.4, stagger: 0.1 })`.
**Interaction:** None — the piece is autonomous. It is suitable as a hero or a section divider that reads as a living poster. GSAP's global timeline pause (tab hidden / off-screen) freezes both loops; they resume in phase.
**Responsive behavior:** Row size is driven by viewport height (`8.6vh`, 7.2vh on phones) so all seven rows always fit between the chrome bars. Under 768px (`gsap.matchMedia`) travel drops to 0.75 × 4% and shear to ⅔ × 12° so the clipped rows do not look chopped on a narrow screen; the signature line is hidden. Each row is a full-bleed `overflow: hidden` strip so the sliding text never widens the page.
**Accessibility:** With `prefers-reduced-motion: reduce` nothing loops: rows are set to a resting pose (half the travel and shear: `xPercent ±4/2`, `skewX ±12/2`, alternating), the counter reads a fixed "050" and the flip word stays on its first value. The stack is one labelled region (`aria-label="Kinetic"`) so assistive tech is not read 35 repeated words; the counter is `aria-hidden`; the flipping word is in an `aria-live="polite"` container so screen readers can follow if they want to. All text remains real, selectable text.
**Performance rules:** Only `transform` (`xPercent`, `skewX`, `scaleX/Y`, `rotateX`, `yPercent`) and `opacity` are animated; weights are static per row via `font-variation-settings` (animating `wght` would re-shape glyphs every frame). `will-change: transform` sits on `.kt__line` and `.kt__flip-word` only. Everything runs inside `gsap.context()` and is reverted on unmount (`ctx.revert()` kills both loops and restores the DOM text). The two loops are infinite GSAP timelines, so pausing/resuming is handled by the global timeline — no custom rAF.
**Customization knobs:**
- `ROW_DURATION` (2.4s) / `ROW_OFFSET` (0.09s) — tempo and ripple depth.
- `travel` (4) and `shear` (12) — how violent the stack is. 2/6 is elegant, 8/20 is punk.
- `FLIP_PERIOD` (1.6s) and `WORDS[]` — the beat and the vocabulary.
- Accent colour (`#d4ff3a`) — the centre row and the counter.
- `--w` per row — redistribute weights (e.g. 100/900 alternating for a stripe look).
- The two CustomEase strings — swap `ktSwing` for `'power2.inOut'` for a softer, less mechanical feel.
**Acceptance criteria:**
- [ ] Seven rows, weights ascending 100 → 900, even rows italic, centre row #d4ff3a, all visible without scrolling at 1200×750 and 375×667.
- [ ] Rows ripple top-to-bottom with a 0.09s offset and reverse every 2.4s; the motion never stops.
- [ ] Counter runs 000 → 100 → 000 in sync with the stack.
- [ ] The small word flips every 1.6s and the text swap is invisible (happens edge-on).
- [ ] Reduced motion shows the static poster with the counter at 050; no console errors.
---
**Reference implementation (use these exact values and class names):**
```html
<section class="kt" aria-label="Kinetic typography poster">
<header class="kt__chrome kt__chrome--top">
<p class="kt__eyebrow">Text · Kinetic loop</p>
<p class="kt__counter" aria-hidden="true"><span class="kt__counter-value">000</span> / 100</p>
</header>
<div class="kt__stack" aria-label="Kinetic">
<div class="kt__row" style="--w: 100"><span class="kt__line">Kinetic Kinetic Kinetic Kinetic Kinetic</span></div>
<div class="kt__row kt__row--italic" style="--w: 230"><span class="kt__line">Kinetic Kinetic Kinetic Kinetic Kinetic</span></div>
<div class="kt__row" style="--w: 360"><span class="kt__line">Kinetic Kinetic Kinetic Kinetic Kinetic</span></div>
<div class="kt__row kt__row--italic kt__row--accent" style="--w: 500"><span class="kt__line">Kinetic Kinetic Kinetic Kinetic Kinetic</span></div>
<div class="kt__row" style="--w: 640"><span class="kt__line">Kinetic Kinetic Kinetic Kinetic Kinetic</span></div>
<div class="kt__row kt__row--italic" style="--w: 780"><span class="kt__line">Kinetic Kinetic Kinetic Kinetic Kinetic</span></div>
<div class="kt__row" style="--w: 900"><span class="kt__line">Kinetic Kinetic Kinetic Kinetic Kinetic</span></div>
</div>
<footer class="kt__chrome kt__chrome--bottom">
<p class="kt__flip" aria-live="polite">
<span class="kt__flip-label">a study in</span>
<span class="kt__flip-stage"><span class="kt__flip-word">rhythm</span></span>
</p>
<p class="kt__sign">Motion/Lab · Poster 07 · 2026</p>
</footer>
</section>
```
```css
.kt { position: relative; min-height: 100vh; display: grid; grid-template-rows: auto 1fr auto; padding: 2.5rem 6vw; overflow: hidden; background: var(--bg); color: var(--text); }
.kt__chrome { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); position: relative; z-index: 2; }
.kt__counter { font-variant-numeric: tabular-nums; }
.kt__counter-value { color: var(--knob-accent, var(--accent)); }
.kt__stack { display: grid; align-content: center; justify-items: center; padding: 3vh 0; }
.kt__row { width: 100vw; margin-inline: -6vw; overflow: hidden; display: flex; justify-content: center; }
.kt__line {
display: inline-block; white-space: nowrap;
font-family: var(--font-display); font-weight: var(--w, 400); font-size: clamp(2.2rem, 8.6vh, 9.4rem); line-height: .86; letter-spacing: -0.05em;
font-variation-settings: 'opsz' 144, 'wght' var(--w, 400); transform-origin: 50% 50%; will-change: transform;
}
.kt__row--italic .kt__line { font-style: italic; }
.kt__row--accent .kt__line { color: var(--knob-accent, var(--accent)); }
.kt__flip { display: flex; align-items: baseline; gap: .75em; text-transform: none; letter-spacing: 0; }
.kt__flip-label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); }
.kt__flip-stage { display: inline-block; perspective: 700px; min-width: 6ch; }
.kt__flip-word { display: inline-block; font-family: var(--font-display); font-style: italic; font-weight: 300; font-size: clamp(1.6rem, 3.2vw, 2.6rem); letter-spacing: -0.03em; line-height: 1; color: var(--text); transform-origin: 50% 50%; backface-visibility: hidden; will-change: transform; }
.kt__sign { text-align: right; }
@media (max-width: 767px) {
.kt { padding: 1.5rem 5vw; }
.kt__row { margin-inline: -5vw; }
.kt__line { font-size: clamp(2rem, 7.2vh, 5rem); }
.kt__sign { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.kt__line, .kt__flip-word { will-change: auto; }
}
```
```js
// Kinetic Typography Loop — a self-running type poster: yoyo stack, 3D flipping word, snapping counter.
gsap.registerPlugin(CustomEase);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { rowDuration: 2.4, rowOffset: 0.09, travel: 4, shear: 12, flipPeriod: 1.6, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const WORDS = ['rhythm', 'weight', 'tempo', 'shear', 'balance', 'contrast'];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.kt');
const lines = gsap.utils.toArray('.kt__line');
const flipWord = document.querySelector('.kt__flip-word');
const counterValue = document.querySelector('.kt__counter-value');
const firstWord = flipWord.textContent;
let ctx;
function build() {
ctx?.revert();
flipWord.textContent = firstWord;
const accent = String(K('accent')).toLowerCase();
if (accent !== DEFAULTS.accent) section.style.setProperty('--knob-accent', accent); else section.style.removeProperty('--knob-accent');
const ROW_DURATION = K('rowDuration'); // one half-cycle of the stack (the timeline yoyos back)
const ROW_OFFSET = K('rowOffset'); // stagger between rows
const FLIP_PERIOD = Math.max(1.2, K('flipPeriod')); // seconds between word flips (0.5 out + 0.6 in + delay)
ctx = gsap.context(() => {
// Signature eases: a heavy mechanical swing for the stack, a hard snap for the flip.
CustomEase.create('ktSwing', 'M0,0 C0.6,0 0.2,1 1,1');
CustomEase.create('ktSnap', 'M0,0 C0.2,0 0.05,1 1,1');
if (reduced) {
// Final-state poster: rows resting at a gentle alternating shear, counter parked, word static.
const t = K('travel') / 2, sh = K('shear') / 2;
lines.forEach((line, i) => gsap.set(line, { xPercent: (i % 2 ? t : -t), skewX: (i % 2 ? -sh : sh) }));
counterValue.textContent = '050';
return;
}
const mm = gsap.matchMedia();
mm.add({ desktop: '(min-width: 768px)', mobile: '(max-width: 767px)' }, (mmCtx) => {
const { mobile } = mmCtx.conditions;
const travel = mobile ? K('travel') * 0.75 : K('travel'); // xPercent amplitude
const shear = mobile ? K('shear') * 2 / 3 : K('shear'); // skewX amplitude in degrees
// 1. The stack — one yoyo timeline, every row written as fromTo so the loop is deterministic.
const stack = gsap.timeline({ repeat: -1, yoyo: true, repeatDelay: 0.15, defaults: { duration: ROW_DURATION, ease: 'ktSwing' } });
lines.forEach((line, i) => {
const dir = i % 2 === 0 ? -1 : 1;
stack.fromTo(line,
{ xPercent: dir * travel, skewX: dir * shear, scaleY: 1 },
{ xPercent: -dir * travel, skewX: -dir * shear, scaleY: 1.12 },
i * ROW_OFFSET,
);
});
// Centre (accent) row also breathes in size; outer rows fade in and out.
stack.fromTo(lines[3], { scaleX: 1 }, { scaleX: 1.06, ease: 'power2.inOut' }, 0.2);
stack.fromTo([lines[0], lines[6]], { opacity: 0.45 }, { opacity: 1, ease: 'power2.inOut' }, 0);
// Counter 000 → 100 rides the same timeline, so it runs back down on the yoyo.
const counter = { value: 0 };
stack.to(counter, {
value: 100, duration: ROW_DURATION + 6 * ROW_OFFSET, ease: 'none', snap: { value: 1 },
onUpdate: () => { counterValue.textContent = String(Math.round(counter.value)).padStart(3, '0'); },
}, 0);
// 2. The flipping word — rotateX out, swap at edge-on, rotateX in. Period = 0.5 + 0.6 + repeatDelay.
let index = 0;
gsap.set(flipWord, { transformPerspective: 700 });
const flip = gsap.timeline({ repeat: -1, repeatDelay: FLIP_PERIOD - 1.1 });
flip.to(flipWord, { rotateX: -90, yPercent: -40, opacity: 0, duration: 0.5, ease: 'power2.in' })
.call(() => { index = (index + 1) % WORDS.length; flipWord.textContent = WORDS[index]; })
.fromTo(flipWord, { rotateX: 90, yPercent: 40, opacity: 0 }, { rotateX: 0, yPercent: 0, opacity: 1, duration: 0.6, ease: 'ktSnap' });
// 3. Entrance — the stack rises in once, then the loops take over.
gsap.from(lines, { yPercent: 60, duration: 1.1, ease: 'power4.out', stagger: 0.06 });
gsap.from('.kt__stack', { opacity: 0, duration: 0.9, ease: 'power2.out' });
gsap.from(['.kt__chrome--top', '.kt__chrome--bottom'], { opacity: 0, y: 12, duration: 0.8, ease: 'power2.out', delay: 0.4, stagger: 0.1 });
return () => { stack.kill(); flip.kill(); };
});
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());
```
### EFFECT (Framer & Webflow notes): Kinetic Typography Loop
**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 withKineticTypographyLoop(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 `kt`), 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/CustomEase.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: Kinetic Typography Loop
**What it does (visual):** A full-viewport dark poster with thin mono chrome in the corners (an eyebrow top-left, a tabular counter "000 / 100" top-right, a small italic word bottom-left, a signature line bottom-right). The middle is a stack of seven rows, each the same word repeated five times ("Kinetic Kinetic Kinetic…") in a light serif, growing in weight from a hairline 100 at the top to a black 900 at the bottom; even rows are italic and the centre row is the accent colour (#d4ff3a). On load the rows rise into place (60% of their height, 1.1s, staggered 60ms) while the whole stack fades in. Then the loop starts: odd rows slide left while even rows slide right (±4% of their width), every row shears up to ±12° and stretches vertically by 12%, each row 0.09s behind the one above so a ripple runs down the stack; after 2.4s the whole motion reverses (yoyo) and repeats forever with a heavy mechanical ease. The counter snaps from 000 to 100 and back in time with the stack. Independently, every 1.6s the small bottom-left word ("rhythm") tips backwards over its X axis, vanishes edge-on, and a new word ("weight", "tempo", "shear"…) swings in from the other side with a sharp snap.
**Tech stack:**
- GSAP 3.13+ with CustomEase (free in 3.13+).
- 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/CustomEase.min.js"></script>`
- npm: `import gsap from "gsap"; import { CustomEase } from "gsap/CustomEase"; gsap.registerPlugin(CustomEase);`
- Fonts: a variable serif with an italic axis file (demo uses Fraunces 100–900 + Fraunces Italic) and a mono for chrome.
**HTML structure:**
```html
<section class="kt" aria-label="Kinetic typography poster">
<header class="kt__chrome kt__chrome--top">
<p class="kt__eyebrow">Text · Kinetic loop</p>
<p class="kt__counter" aria-hidden="true"><span class="kt__counter-value">000</span> / 100</p>
</header>
<div class="kt__stack" aria-label="Kinetic">
<div class="kt__row" style="--w: 100"><span class="kt__line">Kinetic Kinetic Kinetic Kinetic Kinetic</span></div>
<div class="kt__row kt__row--italic" style="--w: 230"><span class="kt__line">…</span></div>
<div class="kt__row" style="--w: 360"><span class="kt__line">…</span></div>
<div class="kt__row kt__row--italic kt__row--accent" style="--w: 500"><span class="kt__line">…</span></div>
<div class="kt__row" style="--w: 640"><span class="kt__line">…</span></div>
<div class="kt__row kt__row--italic" style="--w: 780"><span class="kt__line">…</span></div>
<div class="kt__row" style="--w: 900"><span class="kt__line">…</span></div>
</div>
<footer class="kt__chrome kt__chrome--bottom">
<p class="kt__flip" aria-live="polite"><span class="kt__flip-label">a study in</span><span class="kt__flip-stage"><span class="kt__flip-word">rhythm</span></span></p>
<p class="kt__sign">Motion/Lab · Poster 07 · 2026</p>
</footer>
</section>
```
**CSS requirements:**
- `.kt`: `position: relative; min-height: 100vh; display: grid; grid-template-rows: auto 1fr auto; padding: 2.5rem 6vw; overflow: hidden; background: var(--bg); color: var(--text);`
- `.kt__chrome`: `display: flex; justify-content: space-between; align-items: baseline;` mono 11px uppercase, `letter-spacing: .14em`, muted. `.kt__counter { font-variant-numeric: tabular-nums }`, `.kt__counter-value { color: var(--accent, #d4ff3a) }`.
- `.kt__stack`: `display: grid; align-content: center; justify-items: center; padding: 3vh 0;`
- `.kt__row`: `width: 100vw; margin-inline: -6vw; overflow: hidden; display: flex; justify-content: center;` (full-bleed clip so the sliding lines never cause horizontal scroll).
- `.kt__line`: `display: inline-block; white-space: nowrap; font-family: var(--font-display); font-weight: var(--w); font-variation-settings: 'opsz' 144, 'wght' var(--w); font-size: clamp(2.2rem, 8.6vh, 9.4rem); line-height: .86; letter-spacing: -0.05em; transform-origin: 50% 50%; will-change: transform;` — sizing by `vh` guarantees seven rows fit any viewport height.
- `.kt__row--italic .kt__line { font-style: italic }`, `.kt__row--accent .kt__line { color: var(--accent, #d4ff3a) }`.
- `.kt__flip-stage`: `display: inline-block; perspective: 700px; min-width: 6ch;` `.kt__flip-word`: display serif italic 300, `font-size: clamp(1.6rem, 3.2vw, 2.6rem); letter-spacing: -0.03em; line-height: 1; transform-origin: 50% 50%; backface-visibility: hidden; will-change: transform;`
- Under 768px: `padding: 1.5rem 5vw`, rows `margin-inline: -5vw`, `.kt__line { font-size: clamp(2rem, 7.2vh, 5rem) }`, hide `.kt__sign`.
**Animation spec (exact values):**
1. Eases: `CustomEase.create('ktSwing', 'M0,0 C0.6,0 0.2,1 1,1')` (heavy in, long settle) and `CustomEase.create('ktSnap', 'M0,0 C0.2,0 0.05,1 1,1')` (near-instant start, long tail).
2. Constants: `ROW_DURATION = 2.4`, `ROW_OFFSET = 0.09`, `FLIP_PERIOD = 1.6`, `travel = 4` (xPercent; ×0.75 under 768px), `shear = 12` (deg; ×⅔ under 768px).
3. Stack timeline: `stack = gsap.timeline({ repeat: -1, yoyo: true, repeatDelay: 0.15, defaults: { duration: 2.4, ease: 'ktSwing' } })`. For each `.kt__line` at index `i`, `dir = i % 2 === 0 ? -1 : 1`:
`stack.fromTo(line, { xPercent: dir * 4, skewX: dir * 12, scaleY: 1 }, { xPercent: -dir * 4, skewX: -dir * 12, scaleY: 1.12 }, i * 0.09)` — targets each line; `xPercent` `±4 → ∓4`, `skewX` `±12 → ∓12`, `scaleY` `1 → 1.12`; position `i * 0.09`.
Extra layers on the same timeline: `stack.fromTo(lines[3], { scaleX: 1 }, { scaleX: 1.06, ease: 'power2.inOut' }, 0.2)`; `stack.fromTo([lines[0], lines[6]], { opacity: 0.45 }, { opacity: 1, ease: 'power2.inOut' }, 0)`.
4. Counter on the stack timeline so it reverses with the yoyo: `counter = { value: 0 }`; `stack.to(counter, { value: 100, duration: 2.4 + 6 * 0.09, ease: 'none', snap: { value: 1 }, onUpdate: () => counterValue.textContent = String(Math.round(counter.value)).padStart(3, '0') }, 0)`.
5. Flip loop: `gsap.set('.kt__flip-word', { transformPerspective: 700 })`; `flip = gsap.timeline({ repeat: -1, repeatDelay: 1.6 - 1.1 })`:
- `.to(word, { rotateX: -90, yPercent: -40, opacity: 0, duration: 0.5, ease: 'power2.in' })`
- `.call(() => { index = (index + 1) % WORDS.length; word.textContent = WORDS[index]; })` with `WORDS = ['rhythm','weight','tempo','shear','balance','contrast']`
- `.fromTo(word, { rotateX: 90, yPercent: 40, opacity: 0 }, { rotateX: 0, yPercent: 0, opacity: 1, duration: 0.6, ease: 'ktSnap' })`
Total period 0.5 + 0.6 + repeatDelay = 1.6s.
6. Entrance (plays once, no property overlap with the loops): `gsap.from(lines, { yPercent: 60, duration: 1.1, ease: 'power4.out', stagger: 0.06 })`, `gsap.from('.kt__stack', { opacity: 0, duration: 0.9, ease: 'power2.out' })`, `gsap.from(['.kt__chrome--top', '.kt__chrome--bottom'], { opacity: 0, y: 12, duration: 0.8, ease: 'power2.out', delay: 0.4, stagger: 0.1 })`.
**Interaction:** None — the piece is autonomous. It is suitable as a hero or a section divider that reads as a living poster. GSAP's global timeline pause (tab hidden / off-screen) freezes both loops; they resume in phase.
**Responsive behavior:** Row size is driven by viewport height (`8.6vh`, 7.2vh on phones) so all seven rows always fit between the chrome bars. Under 768px (`gsap.matchMedia`) travel drops to 0.75 × 4% and shear to ⅔ × 12° so the clipped rows do not look chopped on a narrow screen; the signature line is hidden. Each row is a full-bleed `overflow: hidden` strip so the sliding text never widens the page.
**Accessibility:** With `prefers-reduced-motion: reduce` nothing loops: rows are set to a resting pose (half the travel and shear: `xPercent ±4/2`, `skewX ±12/2`, alternating), the counter reads a fixed "050" and the flip word stays on its first value. The stack is one labelled region (`aria-label="Kinetic"`) so assistive tech is not read 35 repeated words; the counter is `aria-hidden`; the flipping word is in an `aria-live="polite"` container so screen readers can follow if they want to. All text remains real, selectable text.
**Performance rules:** Only `transform` (`xPercent`, `skewX`, `scaleX/Y`, `rotateX`, `yPercent`) and `opacity` are animated; weights are static per row via `font-variation-settings` (animating `wght` would re-shape glyphs every frame). `will-change: transform` sits on `.kt__line` and `.kt__flip-word` only. Everything runs inside `gsap.context()` and is reverted on unmount (`ctx.revert()` kills both loops and restores the DOM text). The two loops are infinite GSAP timelines, so pausing/resuming is handled by the global timeline — no custom rAF.
**Customization knobs:**
- `ROW_DURATION` (2.4s) / `ROW_OFFSET` (0.09s) — tempo and ripple depth.
- `travel` (4) and `shear` (12) — how violent the stack is. 2/6 is elegant, 8/20 is punk.
- `FLIP_PERIOD` (1.6s) and `WORDS[]` — the beat and the vocabulary.
- Accent colour (`#d4ff3a`) — the centre row and the counter.
- `--w` per row — redistribute weights (e.g. 100/900 alternating for a stripe look).
- The two CustomEase strings — swap `ktSwing` for `'power2.inOut'` for a softer, less mechanical feel.
**Acceptance criteria:**
- [ ] Seven rows, weights ascending 100 → 900, even rows italic, centre row #d4ff3a, all visible without scrolling at 1200×750 and 375×667.
- [ ] Rows ripple top-to-bottom with a 0.09s offset and reverse every 2.4s; the motion never stops.
- [ ] Counter runs 000 → 100 → 000 in sync with the stack.
- [ ] The small word flips every 1.6s and the text swap is invisible (happens edge-on).
- [ ] Reduced motion shows the static poster with the counter at 050; no console errors.
Source
Full source code
<section class="kt" aria-label="Kinetic typography poster">
<header class="kt__chrome kt__chrome--top">
<p class="kt__eyebrow">Text · Kinetic loop</p>
<p class="kt__counter" aria-hidden="true"><span class="kt__counter-value">000</span> / 100</p>
</header>
<div class="kt__stack" aria-label="Kinetic">
<div class="kt__row" style="--w: 100"><span class="kt__line">Kinetic Kinetic Kinetic Kinetic Kinetic</span></div>
<div class="kt__row kt__row--italic" style="--w: 230"><span class="kt__line">Kinetic Kinetic Kinetic Kinetic Kinetic</span></div>
<div class="kt__row" style="--w: 360"><span class="kt__line">Kinetic Kinetic Kinetic Kinetic Kinetic</span></div>
<div class="kt__row kt__row--italic kt__row--accent" style="--w: 500"><span class="kt__line">Kinetic Kinetic Kinetic Kinetic Kinetic</span></div>
<div class="kt__row" style="--w: 640"><span class="kt__line">Kinetic Kinetic Kinetic Kinetic Kinetic</span></div>
<div class="kt__row kt__row--italic" style="--w: 780"><span class="kt__line">Kinetic Kinetic Kinetic Kinetic Kinetic</span></div>
<div class="kt__row" style="--w: 900"><span class="kt__line">Kinetic Kinetic Kinetic Kinetic Kinetic</span></div>
</div>
<footer class="kt__chrome kt__chrome--bottom">
<p class="kt__flip" aria-live="polite">
<span class="kt__flip-label">a study in</span>
<span class="kt__flip-stage"><span class="kt__flip-word">rhythm</span></span>
</p>
<p class="kt__sign">Motion/Lab · Poster 07 · 2026</p>
</footer>
</section>
.kt { position: relative; min-height: 100vh; display: grid; grid-template-rows: auto 1fr auto; padding: 2.5rem 6vw; overflow: hidden; background: var(--bg); color: var(--text); }
.kt__chrome { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); position: relative; z-index: 2; }
.kt__counter { font-variant-numeric: tabular-nums; }
.kt__counter-value { color: var(--knob-accent, var(--accent)); }
.kt__stack { display: grid; align-content: center; justify-items: center; padding: 3vh 0; }
.kt__row { width: 100vw; margin-inline: -6vw; overflow: hidden; display: flex; justify-content: center; }
.kt__line {
display: inline-block; white-space: nowrap;
font-family: var(--font-display); font-weight: var(--w, 400); font-size: clamp(2.2rem, 8.6vh, 9.4rem); line-height: .86; letter-spacing: -0.05em;
font-variation-settings: 'opsz' 144, 'wght' var(--w, 400); transform-origin: 50% 50%; will-change: transform;
}
.kt__row--italic .kt__line { font-style: italic; }
.kt__row--accent .kt__line { color: var(--knob-accent, var(--accent)); }
.kt__flip { display: flex; align-items: baseline; gap: .75em; text-transform: none; letter-spacing: 0; }
.kt__flip-label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); }
.kt__flip-stage { display: inline-block; perspective: 700px; min-width: 6ch; }
.kt__flip-word { display: inline-block; font-family: var(--font-display); font-style: italic; font-weight: 300; font-size: clamp(1.6rem, 3.2vw, 2.6rem); letter-spacing: -0.03em; line-height: 1; color: var(--text); transform-origin: 50% 50%; backface-visibility: hidden; will-change: transform; }
.kt__sign { text-align: right; }
@media (max-width: 767px) {
.kt { padding: 1.5rem 5vw; }
.kt__row { margin-inline: -5vw; }
.kt__line { font-size: clamp(2rem, 7.2vh, 5rem); }
.kt__sign { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.kt__line, .kt__flip-word { will-change: auto; }
}
// Kinetic Typography Loop — a self-running type poster: yoyo stack, 3D flipping word, snapping counter.
gsap.registerPlugin(CustomEase);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { rowDuration: 2.4, rowOffset: 0.09, travel: 4, shear: 12, flipPeriod: 1.6, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const WORDS = ['rhythm', 'weight', 'tempo', 'shear', 'balance', 'contrast'];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.kt');
const lines = gsap.utils.toArray('.kt__line');
const flipWord = document.querySelector('.kt__flip-word');
const counterValue = document.querySelector('.kt__counter-value');
const firstWord = flipWord.textContent;
let ctx;
function build() {
ctx?.revert();
flipWord.textContent = firstWord;
const accent = String(K('accent')).toLowerCase();
if (accent !== DEFAULTS.accent) section.style.setProperty('--knob-accent', accent); else section.style.removeProperty('--knob-accent');
const ROW_DURATION = K('rowDuration'); // one half-cycle of the stack (the timeline yoyos back)
const ROW_OFFSET = K('rowOffset'); // stagger between rows
const FLIP_PERIOD = Math.max(1.2, K('flipPeriod')); // seconds between word flips (0.5 out + 0.6 in + delay)
ctx = gsap.context(() => {
// Signature eases: a heavy mechanical swing for the stack, a hard snap for the flip.
CustomEase.create('ktSwing', 'M0,0 C0.6,0 0.2,1 1,1');
CustomEase.create('ktSnap', 'M0,0 C0.2,0 0.05,1 1,1');
if (reduced) {
// Final-state poster: rows resting at a gentle alternating shear, counter parked, word static.
const t = K('travel') / 2, sh = K('shear') / 2;
lines.forEach((line, i) => gsap.set(line, { xPercent: (i % 2 ? t : -t), skewX: (i % 2 ? -sh : sh) }));
counterValue.textContent = '050';
return;
}
const mm = gsap.matchMedia();
mm.add({ desktop: '(min-width: 768px)', mobile: '(max-width: 767px)' }, (mmCtx) => {
const { mobile } = mmCtx.conditions;
const travel = mobile ? K('travel') * 0.75 : K('travel'); // xPercent amplitude
const shear = mobile ? K('shear') * 2 / 3 : K('shear'); // skewX amplitude in degrees
// 1. The stack — one yoyo timeline, every row written as fromTo so the loop is deterministic.
const stack = gsap.timeline({ repeat: -1, yoyo: true, repeatDelay: 0.15, defaults: { duration: ROW_DURATION, ease: 'ktSwing' } });
lines.forEach((line, i) => {
const dir = i % 2 === 0 ? -1 : 1;
stack.fromTo(line,
{ xPercent: dir * travel, skewX: dir * shear, scaleY: 1 },
{ xPercent: -dir * travel, skewX: -dir * shear, scaleY: 1.12 },
i * ROW_OFFSET,
);
});
// Centre (accent) row also breathes in size; outer rows fade in and out.
stack.fromTo(lines[3], { scaleX: 1 }, { scaleX: 1.06, ease: 'power2.inOut' }, 0.2);
stack.fromTo([lines[0], lines[6]], { opacity: 0.45 }, { opacity: 1, ease: 'power2.inOut' }, 0);
// Counter 000 → 100 rides the same timeline, so it runs back down on the yoyo.
const counter = { value: 0 };
stack.to(counter, {
value: 100, duration: ROW_DURATION + 6 * ROW_OFFSET, ease: 'none', snap: { value: 1 },
onUpdate: () => { counterValue.textContent = String(Math.round(counter.value)).padStart(3, '0'); },
}, 0);
// 2. The flipping word — rotateX out, swap at edge-on, rotateX in. Period = 0.5 + 0.6 + repeatDelay.
let index = 0;
gsap.set(flipWord, { transformPerspective: 700 });
const flip = gsap.timeline({ repeat: -1, repeatDelay: FLIP_PERIOD - 1.1 });
flip.to(flipWord, { rotateX: -90, yPercent: -40, opacity: 0, duration: 0.5, ease: 'power2.in' })
.call(() => { index = (index + 1) % WORDS.length; flipWord.textContent = WORDS[index]; })
.fromTo(flipWord, { rotateX: 90, yPercent: 40, opacity: 0 }, { rotateX: 0, yPercent: 0, opacity: 1, duration: 0.6, ease: 'ktSnap' });
// 3. Entrance — the stack rises in once, then the loops take over.
gsap.from(lines, { yPercent: 60, duration: 1.1, ease: 'power4.out', stagger: 0.06 });
gsap.from('.kt__stack', { opacity: 0, duration: 0.9, ease: 'power2.out' });
gsap.from(['.kt__chrome--top', '.kt__chrome--bottom'], { opacity: 0, y: 12, duration: 0.8, ease: 'power2.out', delay: 0.4, stagger: 0.1 });
return () => { stack.kill(); flip.kill(); };
});
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());