Skip to content
jjswnth/ui

Preloader

Loader

A full-screen opening sequence that deals icons and letters into place before lifting away.

J
a
s
w
a
n
t
h

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/loader.tsx
    "use client";
    
    import "@/styles/portfolio/tokens.css";
    import "@/styles/portfolio/loader.css";
    import { useEffect, useRef } from 'react';
    import { gsap } from 'gsap';
    import { LOADER_ICONS } from '@/lib/loader-icons';
    
    const NAME = ['J', 'a', 's', 'w', 'a', 'n', 't', 'h'];
    
    const RISE_DURATION = 0.7;
    const PHASE1_DURATION = 1.5;
    const RISE_STAGGER = (PHASE1_DURATION - RISE_DURATION) / (LOADER_ICONS.length - 1);
    
    const PHASE2_START = PHASE1_DURATION;
    const PHASE2_DURATION = 1.5;
    const LETTER_RISE_DURATION = 0.7;
    const LETTER_STAGGER = (PHASE2_DURATION - LETTER_RISE_DURATION) / (NAME.length - 1);
    const GROW_DURATION = 0.15;
    const BLOW_DURATION = 0.25;
    
    const TOTAL_DURATION = PHASE1_DURATION + PHASE2_DURATION;
    const FADE_DURATION = 0.35;
    
    export default function Loader({ onDone }: { onDone: () => void }) {
      const screenRef = useRef<HTMLDivElement>(null);
      const iconRefs = useRef<(HTMLDivElement | null)[]>([]);
      const letterRefs = useRef<(HTMLDivElement | null)[]>([]);
    
      useEffect(() => {
        const tl = gsap.timeline();
    
        tl.fromTo(
          iconRefs.current,
          { y: '65vh', opacity: 0, scale: 0.5 },
          {
            y: '0vh',
            opacity: 1,
            scale: 1,
            duration: RISE_DURATION,
            stagger: RISE_STAGGER,
            ease: 'back.out(2.2)',
          },
          0
        );
    
        NAME.forEach((_, i) => {
          const t = PHASE2_START + i * LETTER_STAGGER;
    
          tl.to(iconRefs.current[i], { scale: 1.2, duration: GROW_DURATION, ease: 'power1.out' }, t)
            .to(
              iconRefs.current[i],
              { scale: 1.5, opacity: 0, duration: BLOW_DURATION, ease: 'power1.in' },
              t + GROW_DURATION
            )
            .fromTo(
              letterRefs.current[i],
              { y: '65vh', opacity: 0, scale: 0.5 },
              {
                y: '0vh',
                opacity: 1,
                scale: 1,
                duration: LETTER_RISE_DURATION,
                ease: 'back.out(2.2)',
              },
              t
            );
        });
    
        tl.to(
          screenRef.current,
          {
            opacity: 0,
            duration: FADE_DURATION,
            ease: 'power1.inOut',
            onComplete: onDone,
          },
          TOTAL_DURATION
        );
    
        return () => {
          tl.kill();
        };
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, []);
    
      return (
        <div className="loader-screen" ref={screenRef}>
          <div className="loader-row">
            {LOADER_ICONS.map((icon, i) => (
              <div className="loader-slot" key={i}>
                <div
                  className="loader-icon"
                  ref={(el) => {
                    iconRefs.current[i] = el;
                  }}
                >
                  <svg viewBox={icon.box.join(' ')} fill="none" xmlns="http://www.w3.org/2000/svg">
                    <path d={icon.d} fill="#ffffff" />
                  </svg>
                </div>
                <div
                  className="loader-letter"
                  ref={(el) => {
                    letterRefs.current[i] = el;
                  }}
                >
                  {NAME[i]}
                </div>
              </div>
            ))}
          </div>
        </div>
      );
    }
    lib/loader-icons.ts
    export interface LoaderIcon {
      d: string;
      box: [number, number, number, number];
    }
    
    /* extracted from public/svgs/Logos.svg — each entry is one glyph, cropped
       to its own tight bounding box so it can be animated independently */
    export const LOADER_ICONS: LoaderIcon[] = [
      {
        d: 'M20.8333 79.1667H75V60L79.6875 57.7083C80.7986 57.1528 81.684 56.3889 82.3438 55.4167C83.0035 54.4444 83.3333 53.3333 83.3333 52.0833C83.3333 50.9028 83.0035 49.809 82.3438 48.8021C81.684 47.7951 80.7986 47.0139 79.6875 46.4583L75 44.2708V25H55L53.9583 17.9167C53.75 16.3889 53.0729 15.1042 51.9271 14.0625C50.7812 13.0208 49.4444 12.5 47.9167 12.5C46.3194 12.5 44.9479 13.0208 43.8021 14.0625C42.6562 15.1042 41.9792 16.3889 41.7708 17.9167L40.7292 25H20.8333V33.9583C24.7222 35.4167 27.7778 37.7778 30 41.0417C32.2222 44.3055 33.3333 47.9861 33.3333 52.0833C33.3333 56.25 32.2222 59.9653 30 63.2292C27.7778 66.493 24.7222 68.8542 20.8333 70.3125V79.1667ZM20.8333 87.5C18.4722 87.5 16.4931 86.7014 14.8958 85.1042C13.2986 83.5069 12.5 81.5278 12.5 79.1667V63.3333C15.8333 63.3333 18.75 62.2743 21.25 60.1562C23.75 58.0382 25 55.3472 25 52.0833C25 48.8889 23.75 46.25 21.25 44.1667C18.75 42.0833 15.8333 40.9722 12.5 40.8333V25C12.5 22.7083 13.316 20.7465 14.9479 19.1146C16.5799 17.4826 18.5417 16.6667 20.8333 16.6667H33.5417C34.0278 13.125 35.625 10.1562 38.3333 7.76041C41.0417 5.36457 44.2361 4.16666 47.9167 4.16666C51.5278 4.16666 54.6875 5.36457 57.3958 7.76041C60.1042 10.1562 61.7361 13.125 62.2917 16.6667H75C77.2917 16.6667 79.2535 17.4826 80.8854 19.1146C82.5174 20.7465 83.3333 22.7083 83.3333 25V38.9583C85.8333 40.2083 87.8472 42.0139 89.375 44.375C90.9028 46.7361 91.6667 49.3055 91.6667 52.0833C91.6667 54.9305 90.9028 57.5347 89.375 59.8958C87.8472 62.2569 85.8333 64.0278 83.3333 65.2083V79.1667C83.3333 81.5278 82.5174 83.5069 80.8854 85.1042C79.2535 86.7014 77.2917 87.5 75 87.5H20.8333Z',
        box: [9.5, 1.17, 85.17, 89.33],
      },
      {
        d: 'M136.979 89.375C132.535 87.8472 128.507 85.7639 124.896 83.125C121.285 80.4861 118.316 77.3958 115.99 73.8542C113.663 70.3125 112.5 66.5278 112.5 62.5V50L129.167 62.5L122.708 68.9583C124.722 72.5 127.917 75.5556 132.292 78.125C136.667 80.6945 141.181 82.3264 145.833 83.0208V45.8333H133.333V37.5H145.833V32.6042C143.403 31.7014 141.406 30.191 139.844 28.0729C138.281 25.9549 137.5 23.5417 137.5 20.8333C137.5 17.3611 138.715 14.4097 141.146 11.9792C143.576 9.54862 146.528 8.33334 150 8.33334C153.472 8.33334 156.424 9.54862 158.854 11.9792C161.285 14.4097 162.5 17.3611 162.5 20.8333C162.5 23.5417 161.719 25.9549 160.156 28.0729C158.594 30.191 156.597 31.7014 154.167 32.6042V37.5H166.667V45.8333H154.167V83.0208C158.819 82.3264 163.333 80.6945 167.708 78.125C172.083 75.5556 175.278 72.5 177.292 68.9583L170.833 62.5L187.5 50V62.5C187.5 66.5278 186.337 70.3125 184.01 73.8542C181.684 77.3958 178.715 80.4861 175.104 83.125C171.493 85.7639 167.465 87.8472 163.021 89.375C158.576 90.9028 154.236 91.6667 150 91.6667C145.764 91.6667 141.424 90.9028 136.979 89.375ZM150 25C151.181 25 152.17 24.6007 152.969 23.8021C153.767 23.0035 154.167 22.0139 154.167 20.8333C154.167 19.6528 153.767 18.6632 152.969 17.8646C152.17 17.066 151.181 16.6667 150 16.6667C148.819 16.6667 147.83 17.066 147.031 17.8646C146.233 18.6632 145.833 19.6528 145.833 20.8333C145.833 22.0139 146.233 23.0035 147.031 23.8021C147.83 24.6007 148.819 25 150 25Z',
        box: [109.5, 5.33, 81, 89.33],
      },
      {
        d: 'M224.875 70.1042L238 62.1875L251.125 70.2083L247.687 55.2083L259.25 45.2083L244.042 43.8542L238 29.6875L231.958 43.75L216.75 45.1042L228.312 55.2083L224.875 70.1042ZM212.271 87.5L219.042 58.2292L196.333 38.5417L226.333 35.9375L238 8.33334L249.667 35.9375L279.667 38.5417L256.958 58.2292L263.729 87.5L238 71.9792L212.271 87.5Z',
        box: [193.33, 5.33, 89.33, 85.17],
      },
      {
        d: 'M327.917 83.3333C324.444 83.3333 321.493 82.1181 319.062 79.6875C316.632 77.257 315.417 74.3056 315.417 70.8333H323.75C323.75 72.0139 324.149 73.0035 324.948 73.8021C325.747 74.6007 326.736 75 327.917 75C329.097 75 330.087 74.6007 330.885 73.8021C331.684 73.0035 332.083 72.0139 332.083 70.8333C332.083 69.6528 331.684 68.6632 330.885 67.8646C330.087 67.066 329.097 66.6667 327.917 66.6667H288.333V58.3333H327.917C331.389 58.3333 334.34 59.5486 336.771 61.9792C339.201 64.4097 340.417 67.3611 340.417 70.8333C340.417 74.3056 339.201 77.257 336.771 79.6875C334.34 82.1181 331.389 83.3333 327.917 83.3333ZM288.333 41.6667V33.3333H344.583C346.389 33.3333 347.882 32.7431 349.062 31.5625C350.243 30.3819 350.833 28.8889 350.833 27.0833C350.833 25.2778 350.243 23.7847 349.062 22.6042C347.882 21.4236 346.389 20.8333 344.583 20.8333C342.778 20.8333 341.285 21.4236 340.104 22.6042C338.924 23.7847 338.333 25.2778 338.333 27.0833H330C330 22.9861 331.406 19.5313 334.219 16.7188C337.031 13.9063 340.486 12.5 344.583 12.5C348.681 12.5 352.135 13.9063 354.948 16.7188C357.76 19.5313 359.167 22.9861 359.167 27.0833C359.167 31.1806 357.76 34.6354 354.948 37.4479C352.135 40.2604 348.681 41.6667 344.583 41.6667H288.333ZM357.083 75V66.6667C358.889 66.6667 360.382 66.0764 361.562 64.8958C362.743 63.7153 363.333 62.2222 363.333 60.4167C363.333 58.6111 362.743 57.1181 361.562 55.9375C360.382 54.7569 358.889 54.1667 357.083 54.1667H288.333V45.8333H357.083C361.181 45.8333 364.635 47.2396 367.448 50.0521C370.26 52.8646 371.667 56.3194 371.667 60.4167C371.667 64.5139 370.26 67.9688 367.448 70.7813C364.635 73.5938 361.181 75 357.083 75Z',
        box: [285.33, 9.5, 89.33, 76.83],
      },
      {
        d: 'M409.813 67.7083L422 60.3125L434.188 67.7083L430.958 53.8542L441.792 44.4792L427.521 43.3333L422 30.2083L416.479 43.3333L402.208 44.4792L413.042 53.8542L409.813 67.7083ZM422 97.0833L408.042 83.3333H388.667V63.9583L374.917 50L388.667 36.0417V16.6667H408.042L422 2.91666L435.958 16.6667H455.333V36.0417L469.083 50L455.333 63.9583V83.3333H435.958L422 97.0833ZM422 85.4167L432.417 75H447V60.4167L457.417 50L447 39.5833V25H432.417L422 14.5833L411.583 25H397V39.5833L386.583 50L397 60.4167V75H411.583L422 85.4167Z',
        box: [371.92, -0.08, 100.17, 100.17],
      },
      {
        d: 'M488.833 87.5L484.667 83.3333L518 8.33334L551.333 83.3333L547.167 87.5L518 75L488.833 87.5ZM497.583 74.5833L518 65.8333L538.417 74.5833L518 28.75L497.583 74.5833Z',
        box: [481.67, 5.33, 72.67, 85.17],
      },
      {
        d: 'M593.75 88.3854C588.681 86.1979 584.271 83.2292 580.521 79.4792C576.771 75.7292 573.802 71.3195 571.615 66.25C569.427 61.1806 568.333 55.7639 568.333 50C568.333 44.2361 569.427 38.8195 571.615 33.75C573.802 28.6806 576.771 24.2708 580.521 20.5208C584.271 16.7708 588.681 13.8021 593.75 11.6146C598.819 9.42709 604.236 8.33334 610 8.33334C615.764 8.33334 621.181 9.42709 626.25 11.6146C631.319 13.8021 635.729 16.7708 639.479 20.5208C643.229 24.2708 646.198 28.6806 648.385 33.75C650.573 38.8195 651.667 44.2361 651.667 50C651.667 55.7639 650.573 61.1806 648.385 66.25C646.198 71.3195 643.229 75.7292 639.479 79.4792C635.729 83.2292 631.319 86.1979 626.25 88.3854C621.181 90.5729 615.764 91.6667 610 91.6667C604.236 91.6667 598.819 90.5729 593.75 88.3854ZM635.104 75.1042C641.979 68.2292 645.417 59.8611 645.417 50C645.417 40.1389 641.979 31.7708 635.104 24.8958C628.229 18.0208 619.861 14.5833 610 14.5833C600.139 14.5833 591.771 18.0208 584.896 24.8958C578.021 31.7708 574.583 40.1389 574.583 50C574.583 59.8611 578.021 68.2292 584.896 75.1042C591.771 81.9792 600.139 85.4167 610 85.4167C619.861 85.4167 628.229 81.9792 635.104 75.1042ZM592.292 67.7083C587.431 62.8472 585 56.9445 585 50C585 43.0556 587.431 37.1528 592.292 32.2917C597.153 27.4306 603.056 25 610 25C616.944 25 622.847 27.4306 627.708 32.2917C632.569 37.1528 635 43.0556 635 50C635 56.9445 632.569 62.8472 627.708 67.7083C622.847 72.5695 616.944 75 610 75C603.056 75 597.153 72.5695 592.292 67.7083ZM623.281 63.2813C626.927 59.6354 628.75 55.2083 628.75 50C628.75 44.7917 626.927 40.3646 623.281 36.7188C619.635 33.0729 615.208 31.25 610 31.25C604.792 31.25 600.365 33.0729 596.719 36.7188C593.073 40.3646 591.25 44.7917 591.25 50C591.25 55.2083 593.073 59.6354 596.719 63.2813C600.365 66.9271 604.792 68.75 610 68.75C615.208 68.75 619.635 66.9271 623.281 63.2813ZM604.115 55.8854C602.483 54.2535 601.667 52.2917 601.667 50C601.667 47.7083 602.483 45.7465 604.115 44.1146C605.747 42.4827 607.708 41.6667 610 41.6667C612.292 41.6667 614.253 42.4827 615.885 44.1146C617.517 45.7465 618.333 47.7083 618.333 50C618.333 52.2917 617.517 54.2535 615.885 55.8854C614.253 57.5174 612.292 58.3333 610 58.3333C607.708 58.3333 605.747 57.5174 604.115 55.8854Z',
        box: [565.33, 5.33, 89.33, 89.33],
      },
      {
        d: 'M698 89.3958L693.729 85.5417C686.384 78.7972 680.312 72.9792 675.512 68.0875C670.712 63.1958 666.889 58.8229 664.042 54.9687C661.194 51.1146 659.198 47.625 658.052 44.5C656.906 41.375 656.333 38.2153 656.333 35.0208C656.333 28.7604 658.434 23.5319 662.635 19.3354C666.837 15.1396 672.028 13.0417 678.208 13.0417C682.167 13.0417 685.83 13.9792 689.198 15.8542C692.566 17.7292 695.5 20.4375 698 23.9792C700.917 20.2292 704.007 17.4687 707.271 15.6979C710.535 13.9271 714.042 13.0417 717.792 13.0417C723.972 13.0417 729.163 15.1396 733.365 19.3354C737.566 23.5319 739.667 28.7604 739.667 35.0208C739.667 38.2153 739.094 41.375 737.948 44.5C736.802 47.625 734.806 51.1146 731.958 54.9687C729.111 58.8229 725.287 63.1958 720.487 68.0875C715.688 72.9792 709.616 78.7972 702.271 85.5417L698 89.3958ZM698 81.1667C705.031 74.7083 710.816 69.1701 715.356 64.5521C719.897 59.934 723.503 55.8889 726.177 52.4167C728.851 48.9444 730.726 45.8493 731.802 43.1312C732.878 40.4139 733.417 37.7153 733.417 35.0354C733.417 30.4424 731.958 26.6701 729.042 23.7187C726.125 20.7674 722.383 19.2917 717.815 19.2917C714.237 19.2917 710.925 20.3854 707.88 22.5729C704.835 24.7604 702.375 27.8333 700.5 31.7917H695.396C693.59 27.9028 691.165 24.8472 688.12 22.625C685.075 20.4028 681.763 19.2917 678.185 19.2917C673.617 19.2917 669.875 20.7674 666.958 23.7187C664.042 26.6701 662.583 30.4486 662.583 35.0542C662.583 37.7403 663.122 40.4549 664.198 43.1979C665.274 45.941 667.149 49.066 669.823 52.5729C672.497 56.0799 676.125 60.125 680.708 64.7083C685.292 69.2917 691.056 74.7778 698 81.1667Z',
        box: [653.33, 10.04, 89.33, 82.35],
      },
    ];
    styles/portfolio/loader.css
    .loader-screen {
      position: fixed;
      inset: 0;
      z-index: 999;
      background: #3965fa;
      display: grid;
      place-items: center;
    }
    
    .loader-row {
      display: flex;
      align-items: center;
      gap: clamp(14px, 3vw, 36px);
      padding: 0 clamp(20px, 6vw, 60px);
    }
    
    .loader-slot {
      display: grid;
      place-items: center;
      flex: 0 0 auto;
    }
    
    .loader-icon {
      grid-area: 1 / 1;
      width: clamp(24px, 4.2vw, 56px);
      will-change: transform, opacity;
    }
    
    .loader-icon svg {
      display: block;
      width: 100%;
      height: auto;
    }
    
    .loader-letter {
      grid-area: 1 / 1;
      font-family: inherit;
      font-weight: 700;
      font-size: clamp(28px, 5vw, 64px);
      line-height: 1;
      color: #ffffff;
      will-change: transform, opacity;
    }
    
    /* The loader is server-rendered now, so its markup is on screen before any
       JS arrives. Both layers start hidden — GSAP's fromTo writes the visible
       state the moment it runs — so a slow bundle shows a clean blue screen, not
       a frozen row of icons. And if the bundle never arrives at all, the screen
       lets go on its own after eight seconds rather than blocking the page. */
    .loader-icon,
    .loader-letter {
      opacity: 0;
    }
    .loader-screen {
      animation: loader-timeout 0.5s ease 8s forwards;
    }
    @keyframes loader-timeout {
      to {
        opacity: 0;
        visibility: hidden;
      }
    }
    styles/portfolio/tokens.css
    /* Design tokens from the jswnth.com portfolio. Global element rules from
       its theme.css are deliberately left out so nothing leaks into a host page. */
    :root {
      /* primary palette */
      --color-navy-blue: #1b1b39;
      --color-purposium-blue: #3965fa;
      --color-soft-blue: #99b7fc;
      --color-pale-blue: #e9ebff;
      --color-base-white: #ffffff;
      --color-light-gray: #ededed;
      --color-dark-gray: #202020;
    
      /* secondary palette */
      --color-deep-teal: #215452;
      --color-green: #8cc63e;
      --color-pale-green: #e0ffb7;
      --color-yellow: #ffd16b;
      --color-pale-yellow: #fff2d6;
      --color-orange: #ff6d47;
      --color-brick: #6f3432;
    
      --bg: #f4f4f6;
      --ink: var(--color-navy-blue);
      --dim: #6b6b76;
      --accent: var(--color-purposium-blue);
      /* buttons + the hero's scroll wipe share this one */
      --cta: #000000;
      --pad: clamp(20px, 4vw, 56px);
    
      --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
      --display: "Caacupe One", system-ui, sans-serif;
    
      /* printed-paper tooth, reused by every sheet on the page */
      --paper-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='260' height='260'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
    }

Props

PropTypeDefaultDescription
onDone() => voidFires once the sequence has finished and the screen is clear.