forked from BRETT/BRETT
Initial Commit
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted, type CSSProperties } from 'vue';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 props = withDefaults(defineProps<DepthTextProps>(), {
|
||||
text: 'Elevate',
|
||||
layers: 34,
|
||||
depth: 2.4,
|
||||
faceColor: '#f8fafc',
|
||||
depthColor: '#117b5a',
|
||||
tilt: 7.5,
|
||||
pointerTracking: true,
|
||||
smoothing: 0.14,
|
||||
perspective: 900,
|
||||
autoOrbit: true,
|
||||
orbitSpeed: 0.35,
|
||||
fontSize: 'clamp(3rem, 12vw, 7rem)',
|
||||
fontWeight: 900,
|
||||
shadow: true
|
||||
});
|
||||
|
||||
const rootRef = ref<HTMLSpanElement | null>(null);
|
||||
const stageRef = ref<HTMLSpanElement | null>(null);
|
||||
|
||||
const safeLayers = computed(() => clamp(Math.round(Number(props.layers) || 1), 2, MAX_LAYERS));
|
||||
const safeDepth = computed(() => clamp(Number(props.depth) || 0, 0, 12));
|
||||
const safeTilt = computed(() => clamp(Number(props.tilt) || 0, 0, 12));
|
||||
const safeSmoothing = computed(() => clamp(Number(props.smoothing) || 0.14, 0.02, 0.35));
|
||||
const safePerspective = computed(() => clamp(Number(props.perspective) || 900, 300, 2000));
|
||||
const safeOrbitSpeed = computed(() => clamp(Number(props.orbitSpeed) || 0, 0, 2));
|
||||
|
||||
const baseRotation = computed(() => ({ x: -safeTilt.value * 0.32, y: safeTilt.value * 0.42 }));
|
||||
|
||||
const depthLayers = computed<DepthLayer[]>(() =>
|
||||
Array.from({ length: safeLayers.value }, (_, layerIndex) => {
|
||||
const index = safeLayers.value - layerIndex;
|
||||
return {
|
||||
index,
|
||||
color: getLayerColor(props.faceColor, props.depthColor, index, safeLayers.value),
|
||||
transform: `translateZ(${-index * safeDepth.value}px)`
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const rootStyle = computed<CSSProperties>(() => ({
|
||||
perspective: `${safePerspective.value}px`,
|
||||
perspectiveOrigin: '50% 50%',
|
||||
contain: 'layout paint',
|
||||
isolation: 'isolate'
|
||||
}));
|
||||
|
||||
const stageStyle = computed<CSSProperties>(() => ({
|
||||
transformStyle: 'preserve-3d',
|
||||
transform: getTransform(baseRotation.value.x, baseRotation.value.y),
|
||||
transformOrigin: '50% 50%',
|
||||
willChange: 'transform'
|
||||
}));
|
||||
|
||||
const textStyle = computed<CSSProperties>(() => ({
|
||||
fontSize: props.fontSize,
|
||||
fontWeight: props.fontWeight,
|
||||
lineHeight: 0.86,
|
||||
letterSpacing: '-0.065em',
|
||||
whiteSpace: 'nowrap',
|
||||
userSelect: 'none',
|
||||
transformStyle: 'preserve-3d',
|
||||
backfaceVisibility: 'hidden',
|
||||
fontKerning: 'normal',
|
||||
textRendering: 'geometricPrecision'
|
||||
}));
|
||||
|
||||
let stopMotion: (() => void) | null = null;
|
||||
|
||||
const setupMotion = () => {
|
||||
const root = rootRef.value;
|
||||
const stage = stageRef.value;
|
||||
if (!root || !stage || typeof window === 'undefined') return;
|
||||
|
||||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const finePointer = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
|
||||
const canTrackPointer = props.pointerTracking && finePointer && !reducedMotion;
|
||||
|
||||
if (reducedMotion) {
|
||||
stage.style.transform = getTransform(baseRotation.value.x, baseRotation.value.y);
|
||||
return;
|
||||
}
|
||||
|
||||
let frameId = 0;
|
||||
let activePointer = false;
|
||||
const startTime = performance.now();
|
||||
const current = { ...baseRotation.value };
|
||||
const target = { ...baseRotation.value };
|
||||
|
||||
const applyTransform = () => {
|
||||
stage.style.transform = getTransform(current.x, current.y);
|
||||
};
|
||||
|
||||
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.value.x - y * safeTilt.value;
|
||||
target.y = baseRotation.value.y + x * safeTilt.value;
|
||||
};
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
activePointer = false;
|
||||
target.x = baseRotation.value.x;
|
||||
target.y = baseRotation.value.y;
|
||||
};
|
||||
|
||||
if (canTrackPointer) {
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerleave', handlePointerLeave);
|
||||
window.addEventListener('blur', handlePointerLeave);
|
||||
}
|
||||
|
||||
const tick = (now: number) => {
|
||||
if ((!canTrackPointer || !activePointer) && props.autoOrbit) {
|
||||
const elapsed = (now - startTime) / 1000;
|
||||
const orbit = elapsed * safeOrbitSpeed.value * Math.PI * 2;
|
||||
const fallbackAmount = canTrackPointer ? 0.18 : 0.55;
|
||||
target.x = baseRotation.value.x + Math.sin(orbit) * safeTilt.value * fallbackAmount;
|
||||
target.y = baseRotation.value.y + Math.cos(orbit * 0.85) * safeTilt.value * fallbackAmount;
|
||||
}
|
||||
|
||||
current.x += (target.x - current.x) * safeSmoothing.value;
|
||||
current.y += (target.y - current.y) * safeSmoothing.value;
|
||||
applyTransform();
|
||||
frameId = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
applyTransform();
|
||||
frameId = requestAnimationFrame(tick);
|
||||
|
||||
stopMotion = () => {
|
||||
if (canTrackPointer) {
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerleave', handlePointerLeave);
|
||||
window.removeEventListener('blur', handlePointerLeave);
|
||||
}
|
||||
cancelAnimationFrame(frameId);
|
||||
};
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setupMotion();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopMotion?.();
|
||||
stopMotion = null;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.autoOrbit, props.pointerTracking, safeOrbitSpeed.value, safeSmoothing.value, safeTilt.value],
|
||||
() => {
|
||||
stopMotion?.();
|
||||
stopMotion = null;
|
||||
setupMotion();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span ref="rootRef" class="inline-block" :style="rootStyle">
|
||||
<span ref="stageRef" class="inline-grid relative place-items-center" :style="stageStyle">
|
||||
<span
|
||||
v-for="layer in depthLayers"
|
||||
:key="layer.index"
|
||||
aria-hidden="true"
|
||||
class="inline-block z-0 absolute inset-0 brightness-95 saturate-95 pointer-events-none transition-[transform,color] duration-300 ease-out"
|
||||
:style="{ ...textStyle, color: layer.color, transform: layer.transform }"
|
||||
>
|
||||
{{ text }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-block z-10 relative transition-[color] duration-300"
|
||||
: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>
|
||||
</template>
|
||||
Reference in New Issue
Block a user