Skip to content
jswnthui

Preloader

Logo Loader

The studio mark traces itself in outline, fills in electric green, then blows up past the edges of the screen to reveal the page.

kinematics.

Installation

  1. 1. Install the dependencies.

    terminal
    npm install gsap clsx tailwind-merge
  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/logo-loader.tsx
    'use client'
    
    import { useLayoutEffect, useRef, useState } from 'react'
    import gsap from 'gsap'
    import { cn } from '@/lib/utils'
    import { usePrefersReducedMotion } from '@/lib/use-prefers-reduced-motion'
    
    // Group.svg's three shapes, reproduced here (not consumed via next/image)
    // because the draw-on effect needs direct <path> refs to call
    // getTotalLength()/animate strokeDashoffset — an <img>/<Image> only gives you
    // an opaque raster/vector blob, not addressable path nodes. Coordinates and
    // the nested translate(0,84.705711) group are copied verbatim from
    // public/images/Group.svg so this stays pixel-identical to the real logo.
    const BASE_PATH =
      'M133.477,333.223L250.199,224.263L366.922,333.222L298.083,333.222L298.083,441.232L202.004,441.232L202.004,333.223L133.477,333.223Z'
    const CHEVRON_PATH =
      'M133.477,150.488L133.499,206.339L250,102.085L366.487,206.18L366.922,149.631L250,46.83L133.477,150.488Z'
    
    type LogoLoaderProps = {
      /** Fires once the mark has blown past the edges and the screen is clear. */
      onDone?: () => void
      /** Cover the nearest positioned ancestor instead of the viewport, and
          leave the page's scrolling alone. For showing it inside a card. */
      contained?: boolean
      className?: string
    }
    
    export default function LogoLoader({ onDone, contained = false, className }: LogoLoaderProps) {
      const containerRef = useRef<HTMLDivElement>(null)
      const svgWrapRef = useRef<HTMLDivElement>(null)
      const pathRefs = useRef<(SVGPathElement | null)[]>([])
      const [hidden, setHidden] = useState(false)
      const reducedMotion = usePrefersReducedMotion()
    
      useLayoutEffect(() => {
        // Full-screen, the page is held still underneath until the loader clears.
        const html = document.documentElement
        const previousOverflow = html.style.overflow
        if (!contained) html.style.overflow = 'hidden'
    
        const paths = pathRefs.current.filter((p): p is SVGPathElement => p !== null)
    
        // Reduced-motion visitors skip straight to the finished state instead of
        // sitting through a scroll-blocking animation they explicitly opted out of.
        if (reducedMotion) {
          html.style.overflow = previousOverflow
          setHidden(true)
          onDone?.()
          return
        }
    
        const lengths = paths.map((p) => p.getTotalLength())
        paths.forEach((p, i) => {
          gsap.set(p, {
            strokeDasharray: lengths[i],
            strokeDashoffset: lengths[i],
            fillOpacity: 0,
          })
        })
    
        const tl = gsap.timeline({
          onComplete: () => {
            html.style.overflow = previousOverflow
            setHidden(true)
            onDone?.()
          },
        })
    
        // 1. Outline draws in, stroke first.
        tl.to(paths, {
          strokeDashoffset: 0,
          duration: 1.1,
          ease: 'power2.inOut',
          stagger: 0.15,
        })
          // 2. Once traced, the outline fills in solid brand green.
          .to(paths, { fillOpacity: 1, duration: 0.35, ease: 'power1.out' }, '-=0.15')
          // 3. The whole mark blows up past the viewport edges...
          .to(
            svgWrapRef.current,
            { scale: 18, duration: 0.9, ease: 'power3.in' },
            '+=0.2'
          )
          // ...and the loader dissolves right as it clears the screen, revealing the page.
          .to(containerRef.current, { opacity: 0, duration: 0.35, ease: 'power1.in' }, '-=0.35')
    
        return () => {
          tl.kill()
          html.style.overflow = previousOverflow
        }
        // onDone is read once the timeline finishes, not a reason to restart it.
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [reducedMotion, contained])
    
      if (hidden) return null
    
      return (
        <div
          ref={containerRef}
          className={cn(
            'inset-0 z-[200] flex items-center justify-center overflow-hidden bg-kine-bg',
            contained ? 'absolute' : 'fixed',
            className,
          )}
          aria-hidden="true"
        >
          <div ref={svgWrapRef} className="w-[26vw] max-w-[200px] min-w-[100px]">
            <svg viewBox="0 0 234 395" xmlns="http://www.w3.org/2000/svg" className="h-full w-full overflow-visible">
              <g transform="translate(-133.476631,-46.829601)">
                <path
                  ref={(el) => { pathRefs.current[0] = el }}
                  d={BASE_PATH}
                  fill="var(--color-kine-primary)"
                  stroke="var(--color-kine-primary)"
                  strokeWidth={3}
                  strokeLinejoin="round"
                />
                <path
                  ref={(el) => { pathRefs.current[1] = el }}
                  d={CHEVRON_PATH}
                  fill="var(--color-kine-primary)"
                  stroke="var(--color-kine-primary)"
                  strokeWidth={3}
                  strokeLinejoin="round"
                />
                <g transform="translate(0,84.705711)">
                  <path
                    ref={(el) => { pathRefs.current[2] = el }}
                    d={CHEVRON_PATH}
                    fill="var(--color-kine-primary)"
                    stroke="var(--color-kine-primary)"
                    strokeWidth={3}
                    strokeLinejoin="round"
                  />
                </g>
              </g>
            </svg>
          </div>
        </div>
      )
    }
    lib/use-prefers-reduced-motion.ts
    'use client'
    
    import { useEffect, useState } from 'react'
    
    // TEMPORARILY DISABLED — hardcoded to false, ignoring the real system
    // preference. On Windows, turning off "Show animations in Windows"
    // (Settings > Accessibility > Visual effects) makes matchMedia report
    // prefers-reduced-motion: reduce in every browser, even for people who
    // never touched a "reduce motion" toggle specifically — that's what made
    // every pinned section on this site go static at once. Disabled here
    // (rather than ripping out every call site) until there's a deliberate
    // decision on how/whether to bring this back — e.g. gated behind an
    // explicit in-app toggle instead of trusting the OS signal outright.
    const FORCE_DISABLED = true
    
    /** True when the user has requested reduced motion at the OS/browser level.
     * Every scroll-jacked "pin" section in this app (hero shrink, StackSection,
     * WorkSection, VisualCode, the /work and /services process sections, etc.)
     * checks this to skip its oversized-scroll-wrapper + sticky-pin mechanic
     * entirely — rather than just slowing the same motion down — so those users
     * get a normal, statically-visible page instead of being carried through a
     * long (sometimes 1000+vh) scroll-jacked, parallaxing, zooming sequence.
     * Starts `false` on the server/first paint (no media-query access there) and
     * corrects on mount — matches this codebase's existing convention of
     * reading `window`-only facts inside an effect rather than during render. */
    export function usePrefersReducedMotion() {
      const [reduced, setReduced] = useState(false)
    
      useEffect(() => {
        if (FORCE_DISABLED) return
        const query = window.matchMedia('(prefers-reduced-motion: reduce)')
        setReduced(query.matches)
    
        const handleChange = (e: MediaQueryListEvent) => setReduced(e.matches)
        query.addEventListener('change', handleChange)
        return () => query.removeEventListener('change', handleChange)
      }, [])
    
      return FORCE_DISABLED ? false : reduced
    }
    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
onDone() => void—Fires once the mark has cleared the screen.
containedbooleanfalseCover the nearest positioned ancestor instead of the viewport, and leave the page's scrolling alone.
classNamestring—Merged onto the overlay.