DepthText
Layered extruded type with parallax that shifts against the pointer.
Live preview
Loading demo…
Installation
npx shadcn@latest add https://reactbits.dev/r/DepthText-TS-TW.json
Source
'use client';
import { useEffect, useMemo, useRef, type CSSProperties } from 'react';
export interface DepthTextProps {
text?: string;
layers?: number;
depth?: number;
faceColor?: string;
depthColor?: string;
tilt?: number;
pointerTracking?: boolean;
smoothing?: number;
perspective?: number;
autoOrbit?: boolean;
orbitSpeed?: number;
fontSize?: string;
fontWeight?: number | string;
shadow?: boolean;
className?: string;
style?: CSSProperties;
}
interface DepthLayer {
index: number;
color: string;
transform: string;
}
const MAX_LAYERS = 64;
const clamp = (value: number, min: number, max: number): number => Math.min(Math.max(value, min), max);
const getLayerColor = (faceColor: string, depthColor: string, index: number, total: number): string => {
const progress = total <= 1 ? 1 : index / total;
const eased = progress * progress;
const faceMix = Math.round((1 - eased) * 72 + 4);
return `color-mix(in srgb, ${faceColor} ${faceMix}%, ${depthColor})`;
};
const getTransform = (rotateX: number, rotateY: number): string =>
`rotateX(${rotateX.toFixed(3)}deg) rotateY(${rotateY.toFixed(3)}deg)`;
const DepthText = ({
text = 'Elevate',
layers = 34,
depth = 2.4,
faceColor = '#f8fafc',
depthColor = '#7c3aed',
tilt = 7.5,
pointerTracking = true,
smoothing = 0.14,
perspective = 900,
autoOrbit = true,
orbitSpeed = 0.35,
fontSize = 'clamp(3rem, 12vw, 7rem)',
fontWeight = 900,
shadow = true,
className = '',
style = {}
}: DepthTextProps) => {
const rootRef = useRef<HTMLSpanElement | null>(null);
const stageRef = useRef<HTMLSpanElement | null>(null);
const safeLayers = clamp(Math.round(Number(layers) || 1), 2, MAX_LAYERS);
const safeDepth = clamp(Number(depth) || 0, 0, 12);
const safeTilt = clamp(Number(tilt) || 0, 0, 12);
const safeSmoothing = clamp(Number(smoothing) || 0.14, 0.02, 0.35);
const safePerspective = clamp(Number(perspective) || 900, 300, 2000);
const safeOrbitSpeed = clamp(Number(orbitSpeed) || 0, 0, 2);
const baseRotation = useMemo(() => ({ x: -safeTilt * 0.32, y: safeTilt * 0.42 }), [safeTilt]);
const depthLayers = useMemo<DepthLayer[]>(
() =>
Array.from({ length: safeLayers }, (_, layerIndex) => {
const index = safeLayers - layerIndex;
return {
index,
color: getLayerColor(faceColor, depthColor, index, safeLayers),
transform: `translateZ(${-index * safeDepth}px)`
};
}),
[safeLayers, safeDepth, faceColor, depthColor]
);
useEffect(() => {
const root = rootRef.current;
const stage = stageRef.current;
if (!root || !stage || typeof window === 'undefined') return undefined;
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const finePointer = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
const canTrackPointer = pointerTracking && finePointer && !reducedMotion;
let frameId = 0;
let activePointer = false;
let startTime = performance.now();
const current = { ...baseRotation };
const target = { ...baseRotation };
const applyTransform = () => {
stage.style.transform = getTransform(current.x, current.y);
};
if (reducedMotion) {
stage.style.transform = getTransform(baseRotation.x, baseRotation.y);
return undefined;
}
const handlePointerMove = (event: PointerEvent) => {
const rect = root.getBoundingClientRect();
if (!rect.width || !rect.height) return;
activePointer = true;
const x = clamp((event.clientX - (rect.left + rect.width / 2)) / (rect.width * 0.8), -1, 1);
const y = clamp((event.clientY - (rect.top + rect.height / 2)) / (rect.height * 0.8), -1, 1);
target.x = baseRotation.x - y * safeTilt;
target.y = baseRotation.y + x * safeTilt;
};
const handlePointerLeave = () => {
activePointer = false;
target.x = baseRotation.x;
target.y = baseRotation.y;
};
if (canTrackPointer) {
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerleave', handlePointerLeave);
window.addEventListener('blur', handlePointerLeave);
}
const tick = (now: number) => {
if ((!canTrackPointer || !activePointer) && autoOrbit) {
const elapsed = (now - startTime) / 1000;
const orbit = elapsed * safeOrbitSpeed * Math.PI * 2;
const fallbackAmount = canTrackPointer ? 0.18 : 0.55;
target.x = baseRotation.x + Math.sin(orbit) * safeTilt * fallbackAmount;
target.y = baseRotation.y + Math.cos(orbit * 0.85) * safeTilt * fallbackAmount;
}
current.x += (target.x - current.x) * safeSmoothing;
current.y += (target.y - current.y) * safeSmoothing;
applyTransform();
frameId = requestAnimationFrame(tick);
};
applyTransform();
frameId = requestAnimationFrame(tick);
return () => {
if (canTrackPointer) {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerleave', handlePointerLeave);
window.removeEventListener('blur', handlePointerLeave);
}
cancelAnimationFrame(frameId);
startTime = 0;
};
}, [autoOrbit, baseRotation, pointerTracking, safeOrbitSpeed, safeSmoothing, safeTilt]);
const rootStyle: CSSProperties = {
...style,
perspective: `${safePerspective}px`,
perspectiveOrigin: '50% 48%',
contain: 'layout paint',
isolation: 'isolate'
};
const stageStyle: CSSProperties = {
transformStyle: 'preserve-3d',
transform: getTransform(baseRotation.x, baseRotation.y),
transformOrigin: '50% 50%',
willChange: 'transform'
};
const textStyle: CSSProperties = {
fontSize,
fontWeight,
lineHeight: 0.86,
letterSpacing: '-0.065em',
whiteSpace: 'nowrap',
userSelect: 'none',
transformStyle: 'preserve-3d',
backfaceVisibility: 'hidden',
fontKerning: 'normal',
textRendering: 'geometricPrecision'
};
return (
<span ref={rootRef} className={`inline-block ${className}`.trim()} style={rootStyle}>
<span ref={stageRef} className="relative inline-grid place-items-center" style={stageStyle}>
{depthLayers.map(layer => (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-0 inline-block brightness-95 saturate-95"
key={layer.index}
style={{ ...textStyle, color: layer.color, transform: layer.transform }}
>
{text}
</span>
))}
<span
className="relative z-10 inline-block"
style={{
...textStyle,
color: faceColor,
textShadow: shadow
? `0 22px 34px color-mix(in srgb, ${depthColor} 36%, transparent), 0 4px 8px rgba(0, 0, 0, 0.28)`
: 'none',
transform: 'translateZ(0.6px)'
}}
>
{text}
</span>
</span>
</span>
);
};
export default DepthText;