StackLoader
PreviewCode
// preview
Color from
#8e8e97
Color mid
#dbc2ff
Color to
#ffffff
// install
pnpmnpmyarnbun
npx shadcn@latest add "https://designpass.dev/r/StackLoader-TS-TW.json"Install the StackLoader component from DesignPass into this project by running:
npx shadcn@latest add "https://designpass.dev/r/StackLoader-TS-TW.json"
If the project has no components.json yet, run `npx shadcn@latest init` first.
Then show me a minimal usage example.// source
tsjs
twcss
/*!
* StackLoader, a DesignPass.dev component by Ernest Liu (ernestliu.com)
* Docs & live playground: https://designpass.dev/components/stack-loader
* MIT licensed, keep this notice in copies and adaptations.
*/
"use client";
import React, { useEffect, useRef, type CSSProperties } from "react";
export interface StackLoaderProps {
/** Overall footprint of the loader (px). */
size?: number;
/** Seconds for one full breathe cycle of the stack. */
speed?: number;
/** Bottom plate tint (dim base). */
colorFrom?: string;
/** Middle plate tint (accent). */
colorMid?: string;
/** Top plate tint (bright peak). */
colorTo?: string;
className?: string;
style?: CSSProperties;
}
/**
* Three glass plates hovering in an isometric stack, breathing apart and
* settling back together like layers of a design file. The stack lives on a
* preserve-3d stage tilted into an isometric camera, and each plate rides its
* own translateZ loop with a stagger so the separation rolls bottom-to-top.
* The plates carry the three-stop gradient (dim base, accent, bright top), so
* elevation reads from color instead of opacity, and the whole stack slowly
* yaws to show off the parallax. Zero dependencies, honors
* prefers-reduced-motion.
*/
export default function StackLoader({
size = 48,
speed = 2,
colorFrom = "#8e8e97",
colorMid = "#dbc2ff",
colorTo = "#ffffff",
className = "",
style,
}: StackLoaderProps) {
const stageRef = useRef<HTMLSpanElement>(null);
const plates = useRef<(HTMLSpanElement | null)[]>([]);
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const lift = size * 0.17;
const breaths = plates.current.map((plate, index) => {
// The bottom plate barely moves and the top plate lifts the most, so
// the stack fans open like cards rather than translating as one block.
const travel = lift * index;
return plate?.animate(
[
{ transform: "translateZ(0px)", offset: 0 },
{ transform: `translateZ(${travel}px)`, offset: 0.4 },
{ transform: `translateZ(${travel}px)`, offset: 0.6 },
{ transform: "translateZ(0px)", offset: 1 },
],
{
duration: speed * 1000,
iterations: Infinity,
easing: "ease-in-out",
delay: -index * speed * 60,
},
);
});
const yaw = stageRef.current?.animate(
[
{ transform: "rotateX(58deg) rotateZ(45deg)" },
{ transform: "rotateX(58deg) rotateZ(405deg)" },
],
{ duration: speed * 4000, iterations: Infinity, easing: "linear" },
);
return () => {
breaths.forEach((breath) => breath?.cancel());
yaw?.cancel();
};
}, [size, speed]);
const plate = size * 0.6;
const tints = [colorFrom, colorMid, colorTo];
return (
<span
role="status"
aria-label="Loading"
className={`inline-block shrink-0 ${className}`}
style={{ width: size, height: size, perspective: size * 5, ...style }}
>
<span
aria-hidden="true"
className="flex h-full w-full items-center justify-center"
style={{ transformStyle: "preserve-3d" }}
>
<span
ref={stageRef}
className="relative block"
style={{
width: plate,
height: plate,
transformStyle: "preserve-3d",
transform: "rotateX(58deg) rotateZ(45deg)",
willChange: "transform",
}}
>
{tints.map((tint, index) => (
<span
key={index}
ref={(el) => {
plates.current[index] = el;
}}
className="absolute inset-0 rounded-md"
style={{
transform: "translateZ(0px)",
backgroundColor: `color-mix(in srgb, ${tint} 45%, transparent)`,
border: `1.5px solid color-mix(in srgb, ${tint} 85%, transparent)`,
boxShadow: `0 0 ${plate * 0.25}px color-mix(in srgb, ${tint} 35%, transparent)`,
transformStyle: "preserve-3d",
willChange: "transform",
}}
/>
))}
</span>
</span>
</span>
);
}
// props
Need the license details? Read the component license.