Motiva
Мои коллекции
← каталог

Page Loader

Прелоадер страницы: прогресс-бар со счётчиком %, шторка уезжает вверх, контент входит каскадом.

gsaploadrevealstaggerintermediate~23 КБreduced-motion ✓

Установка

npx shadcn@latest add https://motiva.pages.dev/r/page-loader.json

зависимости: gsap@^3.15.0

Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"use client";

import { useEffect, useRef } from "react";
import gsap from "gsap";

/**
 * Прелоадер страницы: прогресс-бар + счётчик %, затем шторка уезжает вверх и контент
 * входит stagger-ом. Одноразовый сценарий — в превью перезапускается кнопкой replay.
 * Уважает reduced-motion / data-shot (лоадер снят, контент виден сразу).
 */
export function PageLoader() {
  const root = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const el = root.current;
    if (!el) return;

    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const frozen = document.documentElement.hasAttribute("data-shot") || reduce;

    const overlay = el.querySelector<HTMLElement>(".pl-overlay");
    const fill = el.querySelector<HTMLElement>(".pl-fill");
    const counter = el.querySelector<HTMLElement>(".pl-counter");
    const heroBits = el.querySelectorAll<HTMLElement>(".pl-hero > *");

    if (frozen) {
      overlay?.remove();
      heroBits.forEach((b) => {
        b.style.opacity = "1";
        b.style.transform = "none";
      });
      return;
    }

    const progress = { val: 0 };
    const tl = gsap.timeline();
    tl.to(progress, {
      val: 100,
      duration: 1.4,
      ease: "power2.inOut",
      onUpdate: () => {
        const p = Math.round(progress.val);
        if (fill) fill.style.width = `${p}%`;
        if (counter) counter.textContent = String(p).padStart(3, "0");
      },
    })
      .to(overlay, { yPercent: -100, duration: 0.7, ease: "power3.inOut", delay: 0.15 })
      .set(overlay, { display: "none" })
      .to(heroBits, { y: 0, opacity: 1, duration: 0.9, ease: "power4.out", stagger: 0.12 }, "-=0.25");

    return () => {
      tl.kill();
    };
  }, []);

  return (
    <div ref={root} style={{ position: "absolute", inset: 0, overflow: "hidden", color: "oklch(0.92 0.01 265)" }}>
      <div
        className="pl-hero"
        style={{
          position: "absolute",
          inset: 0,
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
          justifyContent: "center",
          gap: "0.9rem",
        }}
      >
        <h2
          style={{
            margin: 0,
            fontSize: "clamp(2rem, 1rem + 4vw, 3.4rem)",
            fontWeight: 750,
            letterSpacing: "-0.03em",
            opacity: 0,
            transform: "translateY(40px)",
          }}
        >
          Контент готов
        </h2>
        <p
          style={{
            margin: 0,
            fontSize: "0.95rem",
            color: "oklch(0.7 0.02 265)",
            opacity: 0,
            transform: "translateY(40px)",
          }}
        >
          Шторка ушла вверх — страница входит каскадом.
        </p>
        <span
          style={{
            fontFamily: "ui-monospace, monospace",
            fontSize: "0.75rem",
            opacity: 0,
            transform: "translateY(40px)",
            color: "oklch(0.6 0.02 265)",
          }}
        >
          replay ⟳ — прогнать ещё раз
        </span>
      </div>

      <div
        className="pl-overlay"
        style={{
          position: "absolute",
          inset: 0,
          zIndex: 5,
          background: "oklch(0.13 0.015 270)",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
        }}
      >
        <div style={{ display: "grid", gap: 12, justifyItems: "center" }}>
          <div
            style={{
              width: 170,
              height: 2,
              borderRadius: 2,
              background: "oklch(0.3 0.02 270)",
              overflow: "hidden",
            }}
          >
            <div
              className="pl-fill"
              style={{
                width: 0,
                height: "100%",
                borderRadius: 2,
                background: "oklch(0.72 0.15 300)",
              }}
            />
          </div>
          <span
            className="pl-counter"
            style={{ fontFamily: "ui-monospace, monospace", fontSize: "0.8rem", color: "oklch(0.7 0.02 265)" }}
          >
            000
          </span>
        </div>
      </div>
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (порт из fb2025/astro-boilerplate юзера, loader)v1.0.0