Skip to content
jswnthui

Text

Manifesto Text

A paragraph that lights up word by word as it scrolls, with frosted icon boxes that grow and start rolling as the reading reaches them.

Kinematic Studio is a small team of website designers in Kuwait who got tired of watching local businesses settle for slow, templated sites. We believe a website should feel like the best possible version of a brand — fast, alive, and built by hand, not assembled from a theme. Every project starts from a blank canvas and ends exactly where the client's ambition does, not where a template's limitations begin. That's the whole manifesto: no shortcuts, no jank, no compromise.

Scroll inside the card — it lights up word by word, and the icons grow and start to roll

Installation

  1. 1. Install the dependencies.

    terminal
    npm install lucide-react
  2. 2. Add the cn helper, if you don't already have it.

    lib/utils.ts
    import { clsx, type ClassValue } from "clsx";
    import { twMerge } from "tailwind-merge";
    
    export function cn(...inputs: ClassValue[]) {
      return twMerge(clsx(inputs));
    }
  3. 3. Copy the component into your project.

    components/ui/manifesto-text.tsx
    'use client'
    
    import { useEffect, useMemo, useRef } from 'react'
    import { MapPin, Clock, Zap, Hand, Palette, type LucideIcon } from 'lucide-react'
    import { onScroll } from '@/lib/on-scroll'
    import { ManifestoEmojiBox } from '@/components/ui/kinematics/manifesto-emoji-box'
    
    // Real, plain-sentence manifesto — "website designers in Kuwait" lands inside
    // the first 25 words (see CLAUDE.md's SEO rule), and it's also the single
    // most quotable sentence on the page for an AI answer engine.
    const MANIFESTO =
      "Kinematic Studio is a small team of website designers in Kuwait who got tired of watching local businesses settle for slow, templated sites. We believe a website should feel like the best possible version of a brand — fast, alive, and built by hand, not assembled from a theme. Every project starts from a blank canvas and ends exactly where the client's ambition does, not where a template's limitations begin. That's the whole manifesto: no shortcuts, no jank, no compromise."
    
    // Icon markers dropped into the manifesto right after a specific word —
    // each one relates to the phrase it sits beside. Anchored by matching the
    // exact word token rather than a hardcoded index, so editing the text
    // doesn't silently misplace them.
    const MANIFESTO_MARKERS: { afterWord: string; icon: LucideIcon }[] = [
      { afterWord: 'Kuwait', icon: MapPin },
      { afterWord: 'slow,', icon: Clock },
      { afterWord: 'fast,', icon: Zap },
      { afterWord: 'hand,', icon: Hand },
      { afterWord: 'canvas', icon: Palette },
    ]
    
    
    // How many words of scroll the box takes to fully expand — larger than the
    // single-word ramp used for text opacity, so the grow reads as a slow,
    // deliberate reveal rather than an instant snap.
    const MARKER_EASE_WORDS = 10
    
    interface ManifestoTextProps {
      /** The paragraph; it lights up word by word as it scrolls. */
      text?: string
      /** Icons dropped in right after the first occurrence of a given word. */
      markers?: { afterWord: string; icon: LucideIcon }[]
    }
    
    export default function ManifestoText({ text = MANIFESTO, markers = MANIFESTO_MARKERS }: ManifestoTextProps) {
      const words = useMemo(() => text.split(' '), [text])
      const markerIndexes = useMemo(() => markers.map((m) => words.indexOf(m.afterWord)), [markers, words])
      const containerRef = useRef<HTMLParagraphElement>(null)
      const wordRefs = useRef<(HTMLSpanElement | null)[]>([])
      const markerBoxRefs = useRef<(HTMLSpanElement | null)[]>([])
      const markerScaleRefs = useRef<(HTMLSpanElement | null)[]>([])
      const markerGlyphRefs = useRef<(HTMLSpanElement | null)[]>([])
      const total = words.length
    
      // The paragraph's own rect.top at rest (captured on the very first
      // `update()` call, before any scrolling) — progress is measured as
      // distance scrolled *from* that resting position, not as an absolute
      // viewport position. Without this, a paragraph already vertically
      // centered in the first viewport (as this one is) would start with
      // several words already lit on load instead of beginning at word one.
      const startTopRef = useRef<number | null>(null)
    
      useEffect(() => {
        const container = containerRef.current
        if (!container) return
    
        const update = () => {
          const rect = container.getBoundingClientRect()
          if (startTopRef.current === null) startTopRef.current = rect.top
    
          const traveled = startTopRef.current - rect.top
          const progress = Math.min(1, Math.max(0, traveled / (rect.height * 0.9)))
          const litCount = progress * total
    
          wordRefs.current.forEach((el, i) => {
            if (!el) return
            const arrival = Math.min(1, Math.max(0, litCount - i))
            el.style.opacity = String(0.18 + arrival * 0.82)
          })
    
          markerIndexes.forEach((wordIndex, i) => {
            const box = markerBoxRefs.current[i]
            const scaleWrap = markerScaleRefs.current[i]
            const glyph = markerGlyphRefs.current[i]
            if (!box || !scaleWrap || !glyph || wordIndex < 0) return
            // Divided by MARKER_EASE_WORDS so the box takes several words'
            // worth of scroll to fully expand, instead of snapping open across
            // a single word like the text brightening does.
            const arrival = Math.min(1, Math.max(0, (litCount - wordIndex) / MARKER_EASE_WORDS))
            box.style.width = `${1.4 + arrival * 2.6}em`
            box.style.borderColor = `rgba(255,255,255,${0.2 + arrival * 0.35})`
            box.style.backgroundColor = `rgba(255,255,255,${0.08 + arrival * 0.14})`
            scaleWrap.style.transform = `scale(${1 + arrival * 0.4})`
            glyph.style.animationPlayState = arrival > 0.8 ? 'running' : 'paused'
          })
        }
    
        update()
        return onScroll(update)
      }, [total, markerIndexes])
    
      return (
        <p
          ref={containerRef}
          className="max-w-4xl font-kine-display text-3xl uppercase leading-[1.35] tracking-tight md:text-5xl md:leading-[1.3]"
        >
          {words.map((word, i) => {
            const markerIdx = markerIndexes.indexOf(i)
            return (
              <span key={i}>
                <span
                  ref={(el) => {
                    wordRefs.current[i] = el
                  }}
                  className="text-kine-accent"
                  style={{ opacity: 0.18, transition: 'opacity 0.05s linear' }}
                >
                  {word}{' '}
                </span>
                {markerIdx !== -1 && (
                  <ManifestoEmojiBox
                    Icon={markers[markerIdx]!.icon}
                    boxRef={(el) => {
                      markerBoxRefs.current[markerIdx] = el
                    }}
                    scaleRef={(el) => {
                      markerScaleRefs.current[markerIdx] = el
                    }}
                    glyphRef={(el) => {
                      markerGlyphRefs.current[markerIdx] = el
                    }}
                  />
                )}
              </span>
            )
          })}
        </p>
      )
    }
    components/ui/kinematics/manifesto-emoji-box.tsx
    import type { LucideIcon } from 'lucide-react'
    
    // Square at rest (same footprint as one line of text), slowly grows into a
    // frosted-white rectangle and starts rolling the icon once scroll progress
    // reaches it. Scale (JS-driven, per scroll tick) and roll (CSS keyframe,
    // plays once running) live on two separate nested spans — both animate
    // `transform`, and a running CSS animation would otherwise silently win
    // over an inline style on the same element every tick.
    export function ManifestoEmojiBox({
      Icon,
      boxRef,
      scaleRef,
      glyphRef,
    }: {
      Icon: LucideIcon
      boxRef: (el: HTMLSpanElement | null) => void
      scaleRef: (el: HTMLSpanElement | null) => void
      glyphRef: (el: HTMLSpanElement | null) => void
    }) {
      return (
        <span
          ref={boxRef}
          className="relative mx-1 inline-flex shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-white/20 bg-white/10 align-middle backdrop-blur-md transition-[width,border-color,background-color] duration-[1400ms] ease-[cubic-bezier(0.22,1,0.36,1)]"
          style={{ width: '1.4em', height: '1.4em' }}
        >
          <span
            ref={scaleRef}
            className="inline-flex transition-transform duration-[1400ms] ease-[cubic-bezier(0.22,1,0.36,1)]"
            style={{ transform: 'scale(1)' }}
          >
            <span
              ref={glyphRef}
              className="inline-flex"
              style={{ animation: 'manifesto-emoji-roll 1.6s linear infinite', animationPlayState: 'paused' }}
            >
              <Icon className="h-[0.6em] w-[0.6em] text-kine-accent" strokeWidth={2} />
            </span>
          </span>
        </span>
      )
    }
    lib/on-scroll.ts
    /**
     * Calls `handler` whenever anything scrolls — the window or any scroll
     * container on the page — and on resize. Returns the unsubscribe.
     *
     * The Kinematics components were written against a Lenis instance's scroll
     * event. They only ever use it as a tick and measure themselves with
     * getBoundingClientRect, so any scroll will do. Listening in the capture
     * phase catches scroll events from every element, since they do not bubble:
     * the same component works pinned to the page or inside a scrolling card.
     */
    export function onScroll(handler: () => void) {
      const options = { capture: true, passive: true } as const;
      window.addEventListener("scroll", handler, options);
      window.addEventListener("resize", handler);
      return () => {
        window.removeEventListener("scroll", handler, options);
        window.removeEventListener("resize", handler);
      };
    }
    styles/kinematics.css
    /* Kinematic Studio's palette and type, for the components ported from its
       site. Namespaced as kine-* so they sit beside this library's own tokens —
       Kinematics calls its off-white "accent", which here is already the blue.
       Import this from the stylesheet that imports Tailwind, so the @theme
       block turns into utilities: bg-kine-bg, text-kine-primary/70,
       font-kine-display and so on. */
    @theme {
      --color-kine-bg: #0f1113;
      --color-kine-primary: #ceff00;
      --color-kine-surface: #2b2e34;
      --color-kine-accent: #e2e8f0;
    
      /* Audiowide for headlines, Geo for body copy. Geo breaks up at small
         sizes, so tiny labels use the plain sans instead. */
      --font-kine-display: "Audiowide", ui-sans-serif, system-ui, sans-serif;
      --font-kine-body: "Geo", ui-sans-serif, system-ui, sans-serif;
    
      /* Morni, the restaurant client in the Emoji Cursor demo. */
      --font-morni-brand: "Modak", ui-sans-serif, system-ui, sans-serif;
      --font-morni-body: "Poppins", ui-sans-serif, system-ui, sans-serif;
    }
    
    /* Manifesto emoji markers — the box grows from square to rectangle as scroll
       reaches it; once mostly grown, the emoji starts rolling, paused until then
       via animation-play-state set inline. */
    @keyframes manifesto-emoji-roll {
      to {
        transform: rotate(360deg);
      }
    }
    
    /* Expertise cards — small, always-looping visuals tying each card's
       animation to its own subject (motion, layout, load speed, ranking,
       language). CSS keyframes rather than GSAP, since they run for as long as
       the card is mounted. */
    @keyframes expertise-dash {
      to {
        stroke-dashoffset: -24;
      }
    }
    .expertise-flow-line {
      stroke-dasharray: 6 6;
      animation: expertise-dash 1s linear infinite;
    }
    
    @keyframes expertise-block-a {
      0%, 100% { width: 85%; }
      50% { width: 45%; }
    }
    @keyframes expertise-block-b {
      0%, 100% { width: 55%; }
      50% { width: 90%; }
    }
    @keyframes expertise-block-c {
      0%, 100% { width: 70%; }
      50% { width: 30%; }
    }
    .expertise-block-a { animation: expertise-block-a 3.2s ease-in-out infinite; }
    .expertise-block-b { animation: expertise-block-b 3.2s ease-in-out infinite 0.3s; }
    .expertise-block-c { animation: expertise-block-c 3.2s ease-in-out infinite 0.6s; }
    
    @keyframes expertise-fill {
      0% { width: 6%; opacity: 0.5; }
      70% { width: 100%; opacity: 1; }
      85% { width: 100%; opacity: 1; }
      100% { width: 6%; opacity: 0.5; }
    }
    .expertise-fill-bar {
      animation: expertise-fill 2.2s cubic-bezier(0.65, 0, 0.35, 1) infinite;
    }
    
    @keyframes expertise-rank-bar-1 { 0%, 100% { height: 30%; } 50% { height: 80%; } }
    @keyframes expertise-rank-bar-2 { 0%, 100% { height: 55%; } 50% { height: 25%; } }
    @keyframes expertise-rank-bar-3 { 0%, 100% { height: 40%; } 50% { height: 95%; } }
    @keyframes expertise-rank-bar-4 { 0%, 100% { height: 70%; } 50% { height: 50%; } }
    .expertise-rank-1 { animation: expertise-rank-bar-1 2.4s ease-in-out infinite; }
    .expertise-rank-2 { animation: expertise-rank-bar-2 2.4s ease-in-out infinite 0.15s; }
    .expertise-rank-3 { animation: expertise-rank-bar-3 2.4s ease-in-out infinite 0.3s; }
    .expertise-rank-4 { animation: expertise-rank-bar-4 2.4s ease-in-out infinite 0.45s; }
    
    @keyframes expertise-lang-cross {
      0%, 45% { opacity: 1; transform: translateY(0); }
      50%, 95% { opacity: 0; transform: translateY(-6px); }
      100% { opacity: 1; transform: translateY(0); }
    }
    @keyframes expertise-lang-cross-alt {
      0%, 45% { opacity: 0; transform: translateY(6px); }
      50%, 95% { opacity: 1; transform: translateY(0); }
      100% { opacity: 0; transform: translateY(6px); }
    }
    .expertise-lang-a { animation: expertise-lang-cross 3.6s ease-in-out infinite; }
    .expertise-lang-b { animation: expertise-lang-cross-alt 3.6s ease-in-out infinite; }

Props

PropTypeDefaultDescription
textstring—The paragraph.
markers{ afterWord: string; icon: LucideIcon }[]5 markersIcons dropped in after the first occurrence of each word.