Add logo loading screen gated on animation readiness
- Full-screen loader whose only content is the logo animating: the "B" draws in and fills, the base facets trace in, then a wave runs along them until the page is ready (pure CSS, plays before JS) - Loader gate holds the loader until GSAP + fonts and every 3D scene's first frame are ready, then waits 1s (min 1.4s total) before leaving; hard 8s limit, head-script fallback if JS never boots - Scroll reveals, SplitText and scene entry animations start only once the loader leaves, so above-the-fold entries play in view - Internal links fade the loader back in before navigating, so page changes are one seamless dark transition - No loader for no-JS or reduced-motion visitors Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Full-screen loader shown until the page's entry animations are ready
|
||||
* (see ./gate.ts). It's just the logo, animating: the "B" draws itself in
|
||||
* and fills, the five base facets trace in after it, then a wave runs along
|
||||
* the facets until the page is ready. Pure CSS, so it plays before any JS.
|
||||
*
|
||||
* Plain server-rendered markup driven only by classes on <html>, so React
|
||||
* never touches it after hydration:
|
||||
*
|
||||
* - visible only under `html.motion` (set by the head script), so no-JS and
|
||||
* reduced-motion visitors never see it
|
||||
* - `html.loader-done` fading out / hidden
|
||||
* - `html.loader-gone` fully hidden; animations paused
|
||||
* - `html.loader-leaving` fading back in for a page exit
|
||||
*/
|
||||
|
||||
/** Base facets, left → right along the zigzag (wave order). */
|
||||
const FACETS = [
|
||||
"M151.94,875.51L151.94,1042.32L0,958.98L0,786.14L151.94,875.51Z",
|
||||
"M306.83,789.02L306.83,957.3L151.94,1042.32L151.94,875.51L156.15,873.19L306.83,789.02Z",
|
||||
"M461.71,874.03L461.71,1042.32L306.83,957.3L306.83,789.02L461.71,874.03Z",
|
||||
"M610.63,784.67L610.63,952.95L461.71,1042.32L461.71,874.03L464.52,872.35L610.63,784.67Z",
|
||||
"M610.63,619.33L610.63,784.67L461.71,702.53L468.56,698.7L563.9,645.44L610.63,619.33Z",
|
||||
];
|
||||
|
||||
const LETTER =
|
||||
"M610.63,515.37L610.63,619.37L590.72,607.63L461.72,531.55L306.83,617L151.94,702.39L138.33,709.89L0,786.14L0,0L362,0C434.173,0 490.98,15.707 532.42,47.12C534.38,48.613 536.297,50.137 538.17,51.69C575.177,82.423 593.98,125.773 594.58,181.74L594.58,184.49C594.58,225.95 583.717,260.877 561.99,289.27C540.263,317.663 511.353,336.547 475.26,345.92C518.027,355.92 551.283,376.477 575.03,407.59C598.777,438.703 610.643,474.63 610.63,515.37ZM368.49,519.89C378.163,512.21 383,499.673 383,482.28C383,448.193 363.28,431.15 323.84,431.15L222.6,431.15L222.6,531.42L323.87,531.42C343.923,531.42 358.797,527.577 368.49,519.89ZM368,227.61C368,210.23 363.153,197.36 353.46,189C343.767,180.64 328.893,176.463 308.84,176.47L222.6,176.47L222.6,276.74L308.83,276.74C328.877,276.74 343.75,272.74 353.45,264.74C363.15,256.74 368,244.363 368,227.61Z";
|
||||
|
||||
export default function PageLoader() {
|
||||
return (
|
||||
<div className="page-loader" aria-hidden="true">
|
||||
<svg
|
||||
className="page-loader-logo"
|
||||
viewBox="0 0 611 1043"
|
||||
width={56}
|
||||
height={96}
|
||||
fillRule="evenodd"
|
||||
>
|
||||
<path className="pl-shape pl-letter" d={LETTER} pathLength={1} />
|
||||
{FACETS.map((d, i) => (
|
||||
<g
|
||||
key={i}
|
||||
className="pl-facet"
|
||||
style={{ "--i": i } as React.CSSProperties}
|
||||
>
|
||||
<path className="pl-shape" d={d} pathLength={1} />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* # Loader gate
|
||||
*
|
||||
* Keeps the page loader (see ./PageLoader.tsx) up until everything that
|
||||
* animates on load is ready, then dismisses it and opens the gate so the
|
||||
* entry animations start together, in view.
|
||||
*
|
||||
* - `holdLoader(label)` registers something the loader must wait for and
|
||||
* returns an idempotent `release()`. (Motion system + fonts, each 3D scene.)
|
||||
* - `onLoaderDone(cb)` runs `cb` once the loader starts leaving (immediately
|
||||
* if it already has). Entry animations hook in here.
|
||||
* - `showLoader()` brings the overlay back to cover the page before an
|
||||
* internal navigation, so the next page's loader takes over seamlessly.
|
||||
*
|
||||
* Once everything is ready the loader lingers for `READY_HOLD` (and at
|
||||
* least until the logo has drawn itself in) before it leaves.
|
||||
*
|
||||
* State lives on `window` so separately bundled chunks share one gate.
|
||||
* The loader is dismissed anyway after `MAX_WAIT` so a slow or failed
|
||||
* asset never traps the visitor.
|
||||
*/
|
||||
|
||||
const DONE_CLASS = "loader-done";
|
||||
const GONE_CLASS = "loader-gone";
|
||||
const LEAVING_CLASS = "loader-leaving";
|
||||
|
||||
/** Pause after the page is ready, before the loader leaves. */
|
||||
const READY_HOLD = 1000;
|
||||
/** Never leave before the logo has finished drawing itself in. */
|
||||
const MIN_SHOW = 1400;
|
||||
/** Hard limit: leave regardless of anything still pending. */
|
||||
const MAX_WAIT = 8000;
|
||||
/** Overlay fade-out (matches the CSS); its animations pause after. */
|
||||
const FADE_OUT = 700;
|
||||
|
||||
type GateState = {
|
||||
pending: Set<string>;
|
||||
/** Dismissal has started (no new holds accepted). */
|
||||
done: boolean;
|
||||
/** Overlay is leaving; entry animations may run. */
|
||||
open: boolean;
|
||||
listeners: (() => void)[];
|
||||
settleRaf: number;
|
||||
settleTimer: number;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__LOADER?: GateState;
|
||||
}
|
||||
}
|
||||
|
||||
const root = () => document.documentElement;
|
||||
|
||||
function gate(): GateState {
|
||||
if (window.__LOADER) return window.__LOADER;
|
||||
// The head script may already have dropped the loader (JS was slow).
|
||||
const already = root().classList.contains(DONE_CLASS);
|
||||
const state: GateState = {
|
||||
pending: new Set(),
|
||||
done: already,
|
||||
open: already,
|
||||
listeners: [],
|
||||
settleRaf: 0,
|
||||
settleTimer: 0,
|
||||
};
|
||||
window.__LOADER = state;
|
||||
if (!state.done) {
|
||||
const remaining = MAX_WAIT - performance.now();
|
||||
window.setTimeout(() => dismiss(true), Math.max(remaining, 0));
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function dismiss(force = false) {
|
||||
const state = gate();
|
||||
if (state.done) return;
|
||||
state.done = true;
|
||||
if (force && state.pending.size) {
|
||||
console.warn("Loader timed out waiting for:", [...state.pending]);
|
||||
}
|
||||
root().classList.remove(LEAVING_CLASS);
|
||||
root().classList.add(DONE_CLASS);
|
||||
state.open = true;
|
||||
window.setTimeout(() => {
|
||||
if (root().classList.contains(DONE_CLASS)) {
|
||||
root().classList.add(GONE_CLASS);
|
||||
}
|
||||
}, FADE_OUT + 50);
|
||||
state.listeners.splice(0).forEach((cb) => {
|
||||
try {
|
||||
cb();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function cancelSettle(state: GateState) {
|
||||
cancelAnimationFrame(state.settleRaf);
|
||||
window.clearTimeout(state.settleTimer);
|
||||
}
|
||||
|
||||
/**
|
||||
* All holds released: wait a couple of frames for late mounts, then hold
|
||||
* for `READY_HOLD` before leaving. A new hold in the meantime cancels it.
|
||||
*/
|
||||
function settle(state: GateState) {
|
||||
cancelSettle(state);
|
||||
state.settleRaf = requestAnimationFrame(() => {
|
||||
state.settleRaf = requestAnimationFrame(() => {
|
||||
if (state.pending.size || state.done) return;
|
||||
const wait = Math.max(
|
||||
READY_HOLD,
|
||||
MIN_SHOW - performance.now(),
|
||||
);
|
||||
state.settleTimer = window.setTimeout(() => {
|
||||
if (!state.pending.size) dismiss();
|
||||
}, wait);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let holdId = 0;
|
||||
|
||||
export function holdLoader(label: string): () => void {
|
||||
const state = gate();
|
||||
if (state.done) return () => {};
|
||||
const key = `${label}#${++holdId}`;
|
||||
state.pending.add(key);
|
||||
cancelSettle(state);
|
||||
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
state.pending.delete(key);
|
||||
if (!state.done && !state.pending.size) settle(state);
|
||||
};
|
||||
}
|
||||
|
||||
export function onLoaderDone(cb: () => void): () => void {
|
||||
const state = gate();
|
||||
if (state.open) {
|
||||
cb();
|
||||
return () => {};
|
||||
}
|
||||
state.listeners.push(cb);
|
||||
return () => {
|
||||
const i = state.listeners.indexOf(cb);
|
||||
if (i >= 0) state.listeners.splice(i, 1);
|
||||
};
|
||||
}
|
||||
|
||||
export function isLoaderDone() {
|
||||
return gate().open;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fade the overlay back in (without the logo) and resolve once the page
|
||||
* is covered. Used for page exits.
|
||||
*/
|
||||
export function showLoader(duration = 320): Promise<void> {
|
||||
const el = root();
|
||||
el.classList.add(LEAVING_CLASS);
|
||||
el.classList.remove(DONE_CLASS, GONE_CLASS);
|
||||
return new Promise((resolve) => window.setTimeout(resolve, duration));
|
||||
}
|
||||
|
||||
/** Undo `showLoader()` (page restored from the back/forward cache). */
|
||||
export function hideLoader() {
|
||||
const el = root();
|
||||
el.classList.remove(LEAVING_CLASS);
|
||||
el.classList.add(DONE_CLASS, GONE_CLASS);
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
/**
|
||||
* Inline <head> script: runs before first paint so motion elements can be
|
||||
* pre-hidden without a flash. Skipped for reduced-motion users. If the motion
|
||||
* system hasn't booted within 3s (JS error, slow network), everything is
|
||||
* revealed so content is never stuck invisible.
|
||||
* pre-hidden without a flash and the page loader (src/components/loader) is
|
||||
* up from the first frame. Skipped for reduced-motion users (no loader, no
|
||||
* pre-hiding).
|
||||
*
|
||||
* Fallbacks so content is never stuck invisible: if the motion system hasn't
|
||||
* booted within 3s everything is revealed, and if the loader gate never
|
||||
* initialised (JS error, very slow network) the loader is dropped at 5s.
|
||||
* Once the gate is up it enforces its own time limit.
|
||||
*/
|
||||
export const MotionHeadScript = `(function(){try{
|
||||
if(window.matchMedia("(prefers-reduced-motion: reduce)").matches)return;
|
||||
var d=document.documentElement;d.classList.add("motion");
|
||||
setTimeout(function(){if(!window.__MOTION_READY)d.classList.add("motion-fallback");},3000);
|
||||
setTimeout(function(){if(!window.__LOADER)d.classList.add("loader-done");},5000);
|
||||
}catch(e){}})();`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import { SplitText } from "gsap/SplitText";
|
||||
import { hideLoader, onLoaderDone, showLoader } from "../loader/gate";
|
||||
|
||||
/**
|
||||
* # Site-wide motion system
|
||||
@@ -19,6 +20,8 @@ import { SplitText } from "gsap/SplitText";
|
||||
*
|
||||
* Elements are pre-hidden by CSS only once `html.motion` is set by the inline
|
||||
* head script, so no-JS / reduced-motion visitors always see content.
|
||||
* Nothing is animated until the page loader is dismissed (../loader/gate.ts),
|
||||
* so above-the-fold entries play in view instead of behind the overlay.
|
||||
*/
|
||||
|
||||
type Variant =
|
||||
@@ -193,8 +196,9 @@ function scan(root: ParentNode) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Fade the page out before following internal links. */
|
||||
/** Cover the page with the loader before following internal links. */
|
||||
function setupPageExit() {
|
||||
let leaving = false;
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (e.defaultPrevented || e.button !== 0) return;
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
@@ -206,17 +210,15 @@ function setupPageExit() {
|
||||
if (/\.(pdf|png|jpe?g|webp|gif|svg|zip)$/i.test(url.pathname)) return;
|
||||
|
||||
e.preventDefault();
|
||||
gsap.to("main", {
|
||||
autoAlpha: 0,
|
||||
y: -12,
|
||||
duration: 0.28,
|
||||
ease: "power2.in",
|
||||
onComplete: () => location.assign(url.href),
|
||||
});
|
||||
if (leaving) return;
|
||||
leaving = true;
|
||||
showLoader().then(() => location.assign(url.href));
|
||||
};
|
||||
// restore if the page comes back from the back/forward cache
|
||||
const onShow = (e: PageTransitionEvent) => {
|
||||
if (e.persisted) gsap.set("main", { clearProps: "all" });
|
||||
if (!e.persisted) return;
|
||||
leaving = false;
|
||||
hideLoader();
|
||||
};
|
||||
document.addEventListener("click", onClick);
|
||||
window.addEventListener("pageshow", onShow);
|
||||
@@ -241,8 +243,15 @@ export function initMotion() {
|
||||
registered = true;
|
||||
}
|
||||
|
||||
scan(document);
|
||||
setupPageExit();
|
||||
cleanups.push(onLoaderDone(start));
|
||||
|
||||
return destroyMotion;
|
||||
}
|
||||
|
||||
/** Runs once the loader leaves: animate what's on the page, watch for more. */
|
||||
function start() {
|
||||
scan(document);
|
||||
|
||||
// Pick up content React mounts later (tabs, lists, client fetches).
|
||||
observer = new MutationObserver((mutations) => {
|
||||
@@ -264,8 +273,6 @@ export function initMotion() {
|
||||
window.addEventListener("load", onLoad);
|
||||
document.fonts?.ready.then(onLoad).catch(() => {});
|
||||
cleanups.push(() => window.removeEventListener("load", onLoad));
|
||||
|
||||
return destroyMotion;
|
||||
}
|
||||
|
||||
function destroyMotion() {
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
import React from "react";
|
||||
import { holdLoader } from "../loader/gate";
|
||||
|
||||
/** Boot the site-wide GSAP motion system (see ./motion.ts). */
|
||||
/**
|
||||
* Boot the site-wide GSAP motion system (see ./motion.ts). Holds the page
|
||||
* loader until GSAP is loaded and fonts are settled, so SplitText measures
|
||||
* final line breaks.
|
||||
*/
|
||||
export default function useMotion() {
|
||||
React.useEffect(() => {
|
||||
let destroy: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
import("./motion").then(({ initMotion }) => {
|
||||
const release = holdLoader("motion");
|
||||
|
||||
Promise.all([
|
||||
import("./motion"),
|
||||
document.fonts?.ready.catch(() => {}),
|
||||
])
|
||||
.then(([{ initMotion }]) => {
|
||||
if (!cancelled) destroy = initMotion();
|
||||
});
|
||||
})
|
||||
.catch((error) => console.warn("Motion failed to load", error))
|
||||
.finally(release);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
release();
|
||||
destroy?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { holdLoader } from "../loader/gate";
|
||||
|
||||
/**
|
||||
* Each scene is its own lazily-loaded chunk; three.js + the stage are a
|
||||
@@ -49,6 +50,8 @@ export default function SceneCanvas({
|
||||
|
||||
let disposed = false;
|
||||
let dispose: (() => void) | undefined;
|
||||
// The page loader waits for the first frame (or a failure).
|
||||
const release = holdLoader(`scene:${scene}`);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
@@ -69,18 +72,24 @@ export default function SceneCanvas({
|
||||
: l.mobile) ?? {}
|
||||
);
|
||||
},
|
||||
onReady: () => setReady(true),
|
||||
onReady: () => {
|
||||
setReady(true);
|
||||
release();
|
||||
},
|
||||
});
|
||||
if (disposed) handle.dispose();
|
||||
else dispose = handle.dispose;
|
||||
} catch (error) {
|
||||
// No WebGL / failed load: the page simply stays text-only.
|
||||
console.warn(`SceneCanvas(${scene}) failed`, error);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
release();
|
||||
dispose?.();
|
||||
};
|
||||
}, [scene]);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as THREE from "three";
|
||||
import { gsap } from "gsap";
|
||||
import { onLoaderDone } from "../loader/gate";
|
||||
|
||||
/**
|
||||
* # Three.js stage
|
||||
@@ -7,7 +8,8 @@ import { gsap } from "gsap";
|
||||
* Shared renderer / camera / loop for every page graphic. A scene plugs in
|
||||
* by returning a `SceneInstance`; the stage handles framing, resize, pointer
|
||||
* smoothing, the GSAP-driven entry animation, pausing when off-screen and
|
||||
* reduced motion.
|
||||
* reduced motion. The scene renders behind the page loader while it's up
|
||||
* (warming shaders), but the entry only starts once the loader leaves.
|
||||
*/
|
||||
|
||||
export type Stage = {
|
||||
@@ -147,6 +149,7 @@ export async function createStage(opts: StageOptions) {
|
||||
let last = 0;
|
||||
let elapsed = 0;
|
||||
let entryTween: gsap.core.Tween | undefined;
|
||||
let gateOpen = false;
|
||||
|
||||
function loop(now: number) {
|
||||
const dt = Math.min((now - last) / 1000, 0.05);
|
||||
@@ -164,6 +167,11 @@ export async function createStage(opts: StageOptions) {
|
||||
running = true;
|
||||
last = performance.now();
|
||||
raf = requestAnimationFrame(loop);
|
||||
beginEntry();
|
||||
}
|
||||
|
||||
function beginEntry() {
|
||||
if (!gateOpen || !running) return;
|
||||
entryTween ??= gsap.to(stage, {
|
||||
entry: 1,
|
||||
duration: opts.entryDuration ?? 2.8,
|
||||
@@ -194,10 +202,16 @@ export async function createStage(opts: StageOptions) {
|
||||
renderFrame(0, 0);
|
||||
opts.onReady?.();
|
||||
|
||||
const offLoader = onLoaderDone(() => {
|
||||
gateOpen = true;
|
||||
beginEntry();
|
||||
});
|
||||
|
||||
return {
|
||||
stage,
|
||||
dispose() {
|
||||
stop();
|
||||
offLoader();
|
||||
entryTween?.kill();
|
||||
visibility.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
|
||||
@@ -5,6 +5,7 @@ import Footer from "./Footer";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import MobileMenu from "./(sections)/MobileMenu";
|
||||
import useMotion from "@/src/components/motion/use-motion";
|
||||
import PageLoader from "@/src/components/loader/PageLoader";
|
||||
|
||||
type Props = PropsWithChildren;
|
||||
|
||||
@@ -23,6 +24,7 @@ export default function Layout({ children }: Props) {
|
||||
<Footer />
|
||||
</div>
|
||||
<MobileMenu {...{ menuOpen, setMenuOpen }} />
|
||||
<PageLoader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,6 +115,137 @@ html.motion:not(.motion-fallback) [data-reveal-stagger] > * {
|
||||
margin-bottom: -0.08em;
|
||||
}
|
||||
|
||||
/* ---- Page loader (see src/components/loader) ---- */
|
||||
.page-loader {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html.motion .page-loader {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #0a0a0a;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transition:
|
||||
opacity 0.7s cubic-bezier(0.65, 0, 0.35, 1),
|
||||
visibility 0s linear 0s;
|
||||
}
|
||||
|
||||
html.motion.loader-done .page-loader {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.7s cubic-bezier(0.65, 0, 0.35, 1),
|
||||
visibility 0s linear 0.7s;
|
||||
}
|
||||
|
||||
html.loader-gone .page-loader * {
|
||||
animation-play-state: paused !important;
|
||||
}
|
||||
|
||||
html.motion.loader-leaving .page-loader {
|
||||
transition-duration: 0.32s, 0s;
|
||||
}
|
||||
|
||||
/* -- the logo -- */
|
||||
.page-loader-logo {
|
||||
--d0: 0.1s; /* start offset for the whole sequence */
|
||||
width: 56px;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
color: #fff;
|
||||
transition:
|
||||
opacity 0.45s ease,
|
||||
transform 0.7s cubic-bezier(0.65, 0, 0.35, 1);
|
||||
}
|
||||
|
||||
html.loader-done .page-loader-logo {
|
||||
opacity: 0;
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
/* Page exits never show it. */
|
||||
html.loader-leaving .page-loader-logo {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.pl-shape {
|
||||
fill: currentColor;
|
||||
fill-opacity: 0;
|
||||
stroke: currentColor;
|
||||
stroke-width: 9;
|
||||
stroke-linejoin: round;
|
||||
stroke-dasharray: 1 1;
|
||||
stroke-dashoffset: 1;
|
||||
}
|
||||
|
||||
/* 1. the "B" draws itself in, then fills */
|
||||
.pl-letter {
|
||||
animation:
|
||||
pl-draw 1s cubic-bezier(0.65, 0, 0.35, 1) var(--d0) forwards,
|
||||
pl-fill 0.55s ease calc(var(--d0) + 0.65s) forwards;
|
||||
}
|
||||
|
||||
/* 2. the facets trace in and rise into place, left → right */
|
||||
.pl-facet .pl-shape {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation:
|
||||
pl-draw 0.6s ease-out calc(var(--d0) + 0.45s + var(--i) * 0.08s)
|
||||
forwards,
|
||||
pl-fill 0.45s ease calc(var(--d0) + 0.7s + var(--i) * 0.08s)
|
||||
forwards,
|
||||
pl-rise 0.7s cubic-bezier(0.22, 1, 0.36, 1)
|
||||
calc(var(--d0) + 0.45s + var(--i) * 0.08s) both;
|
||||
}
|
||||
|
||||
/* 3. while waiting: a signal runs along the facets */
|
||||
.pl-facet {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: pl-wave 1.8s ease-in-out
|
||||
calc(var(--d0) + 1.35s + var(--i) * 0.11s) infinite;
|
||||
}
|
||||
|
||||
@keyframes pl-draw {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pl-fill {
|
||||
to {
|
||||
fill-opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pl-rise {
|
||||
from {
|
||||
transform: translateY(70px);
|
||||
}
|
||||
to {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pl-wave {
|
||||
0%,
|
||||
45%,
|
||||
100% {
|
||||
transform: none;
|
||||
color: #fff;
|
||||
}
|
||||
20% {
|
||||
transform: translateY(-46px);
|
||||
color: #34d399;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hero graphics (<xl): stacked above the text; fades out downward into it */
|
||||
@media (max-width: 79.999rem) {
|
||||
.hero-graphic {
|
||||
|
||||
Reference in New Issue
Block a user