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.

Loading

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.

prompt.md · Kinetic Typography Loop
### 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>