← каталог
Page Loader
Прелоадер страницы: прогресс-бар со счётчиком %, шторка уезжает вверх, контент входит каскадом.
gsaploadrevealstaggerintermediate~23 КБreduced-motion ✓
Установка
npx shadcn@latest add https://motiva.pages.dev/r/page-loader.jsonзависимости: gsap@^3.15.0
Что нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"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>
);
}
import gsap from "gsap";
/**
* Page Loader — vanilla JS/GSAP (без React). Прогресс-бар + счётчик %, шторка уезжает
* вверх, затем каскадный вход контента.
*
* <div data-pl-overlay style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center">
* <div style="width:170px;height:2px;overflow:hidden"><div data-pl-fill style="width:0;height:100%"></div></div>
* <span data-pl-counter>000</span>
* </div>
* <h1 data-pl-enter style="opacity:0;transform:translateY(40px)">Контент</h1>
* <script type="module">
* import { initPageLoader } from './page-loader.vanilla.js';
* initPageLoader({ onDone: () => console.log('страница вошла') });
* </script>
*
* @param {{ root?: Document|HTMLElement, duration?: number, onDone?: () => void }} [options]
* @returns {() => void} destroy
*/
export function initPageLoader({ root = document, duration = 1.4, onDone } = {}) {
const overlay = root.querySelector("[data-pl-overlay]");
const fill = root.querySelector("[data-pl-fill]");
const counter = root.querySelector("[data-pl-counter]");
const enter = root.querySelectorAll("[data-pl-enter]");
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (frozen) {
overlay?.remove();
enter.forEach((el) => {
el.style.opacity = "1";
el.style.transform = "none";
});
onDone?.();
return () => {};
}
const progress = { val: 0 };
const tl = gsap
.timeline({ onComplete: () => onDone?.() })
.to(progress, {
val: 100,
duration,
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(enter, { y: 0, opacity: 1, duration: 0.9, ease: "power4.out", stagger: 0.12 }, "-=0.25");
return () => tl.kill();
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (порт из fb2025/astro-boilerplate юзера, loader)v1.0.0