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.
import { useState } from "react";
import LogoLoader from "@/components/ui/logo-loader";
export default function LogoLoaderDemo({ fullscreen }: { fullscreen?: boolean }) {
const [run, setRun] = useState(0);
const [done, setDone] = useState(false);
const replay = (
<button
type="button"
onClick={() => {
setDone(false);
setRun((value) => value + 1);
}}
className="mx-auto block rounded-full border border-line bg-surface px-4 py-1.5 text-xs font-medium text-muted transition-colors hover:text-ink"
>
Replay
</button>
);
if (fullscreen) {
return (
<div className="grid min-h-dvh w-full place-items-center bg-kine-bg">
<div className="space-y-6 text-center">
<p className="font-kine-display text-4xl uppercase text-kine-primary">kinematics.</p>
{done && replay}
</div>
{!done && <LogoLoader key={run} onDone={() => setDone(true)} />}
</div>
);
}
return (
<div className="w-full space-y-4">
<div className="relative grid h-96 w-full place-items-center overflow-hidden rounded-card border border-line bg-kine-bg">
<p className="font-kine-display text-3xl uppercase text-kine-primary">kinematics.</p>
{!done && <LogoLoader key={run} contained onDone={() => setDone(true)} />}
</div>
{replay}
</div>
);
}Installation
1. Install the dependencies.
terminal npm install gsap clsx tailwind-merge2. Add the
cnhelper, 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. 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
| Prop | Type | Default | Description |
|---|---|---|---|
| onDone | () => void | — | Fires once the mark has cleared the screen. |
| contained | boolean | false | Cover the nearest positioned ancestor instead of the viewport, and leave the page's scrolling alone. |
| className | string | — | Merged onto the overlay. |