mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 06:29:30 -07:00
Merge branch 'main' into feat/counter
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// Highlighted sidebar items
|
||||
export const NEW = ['Target Cursor', 'Ripple Grid', 'Magic Bento', 'Galaxy', 'Text Type', 'Glass Surface', 'Sticker Peel'];
|
||||
export const NEW = ['Target Cursor', 'Ripple Grid', 'Magic Bento', 'Galaxy', 'Text Type', 'Glass Surface', 'Sticker Peel', 'Scroll Stack'];
|
||||
export const UPDATED = [];
|
||||
|
||||
// Used for main sidebar navigation
|
||||
@@ -63,6 +63,7 @@ export const CATEGORIES = [
|
||||
'Masonry',
|
||||
'Glass Surface',
|
||||
'Magic Bento',
|
||||
'Scroll Stack',
|
||||
'Profile Card',
|
||||
'Dock',
|
||||
'Gooey Nav',
|
||||
@@ -81,7 +82,8 @@ export const CATEGORIES = [
|
||||
'Elastic Slider',
|
||||
'Stack',
|
||||
'Chroma Grid',
|
||||
'Counter'
|
||||
'Counter',
|
||||
'Rolling Gallery'
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -70,6 +70,8 @@ const components = {
|
||||
'stack': () => import('../demo/Components/StackDemo.vue'),
|
||||
'chroma-grid': () => import('../demo/Components/ChromaGridDemo.vue'),
|
||||
'counter': () => import('../demo/Components/CounterDemo.vue'),
|
||||
'rolling-gallery': () => import('../demo/Components/RollingGalleryDemo.vue'),
|
||||
'scroll-stack': () => import('../demo/Components/ScrollStackDemo.vue'),
|
||||
};
|
||||
|
||||
const backgrounds = {
|
||||
|
||||
26
src/constants/code/Components/rollingGalleryCode.ts
Normal file
26
src/constants/code/Components/rollingGalleryCode.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import code from '@content/Components/RollingGallery/RollingGallery.vue?raw';
|
||||
import { createCodeObject } from '@/types/code.ts';
|
||||
|
||||
export const rollingGallery = createCodeObject(code, 'Components/RollingGallery', {
|
||||
installation: `npm install motion-v`,
|
||||
usage: `<template>
|
||||
<RollingGallery
|
||||
:autoplay="true"
|
||||
:pause-on-hover="true"
|
||||
:images="customImages"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import RollingGallery from "./RollingGallery.vue";
|
||||
|
||||
const customImages = [
|
||||
"https://images.unsplash.com/photo-1528181304800-259b08848526?q=80&w=3870&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
|
||||
"https://images.unsplash.com/photo-1506665531195-3566af2b4dfa?q=80&w=3870&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
|
||||
"https://images.unsplash.com/photo-1520250497591-112f2f40a3f4?q=80&w=3456&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
|
||||
"https://images.unsplash.com/photo-1495103033382-fe343886b671?q=80&w=3870&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
|
||||
"https://images.unsplash.com/photo-1506781961370-37a89d6b3095?q=80&w=3264&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
|
||||
// Add more images as needed
|
||||
];
|
||||
</script>`
|
||||
});
|
||||
26
src/constants/code/Components/scrollStackCode.ts
Normal file
26
src/constants/code/Components/scrollStackCode.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import code from '@content/Components/ScrollStack/ScrollStack.vue?raw';
|
||||
import { createCodeObject } from '../../../types/code';
|
||||
|
||||
export const scrollStack = createCodeObject(code, 'Components/ScrollStack', {
|
||||
installation: `npm install lenis`,
|
||||
usage: `<template>
|
||||
<ScrollStack>
|
||||
<ScrollStackItem>
|
||||
<h2>Card 1</h2>
|
||||
<p>This is the first card in the stack</p>
|
||||
</ScrollStackItem>
|
||||
<ScrollStackItem>
|
||||
<h2>Card 2</h2>
|
||||
<p>This is the second card in the stack</p>
|
||||
</ScrollStackItem>
|
||||
<ScrollStackItem>
|
||||
<h2>Card 3</h2>
|
||||
<p>This is the third card in the stack</p>
|
||||
</ScrollStackItem>
|
||||
</ScrollStack>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ScrollStack, { ScrollStackItem } from "./ScrollStack.vue";
|
||||
</script>`
|
||||
});
|
||||
317
src/content/Components/RollingGallery/RollingGallery.vue
Normal file
317
src/content/Components/RollingGallery/RollingGallery.vue
Normal file
@@ -0,0 +1,317 @@
|
||||
<template>
|
||||
<div class="relative h-[500px] w-full overflow-hidden">
|
||||
<div class="absolute top-0 left-0 h-full w-12 z-10 bg-gradient-to-l from-transparent to-[#0b0b0b]" />
|
||||
<div class="absolute top-0 right-0 h-full w-12 z-10 bg-gradient-to-r from-transparent to-[#0b0b0b]" />
|
||||
|
||||
<div class="flex h-full items-center justify-center [perspective:1000px] [transform-style:preserve-3d]">
|
||||
<Motion
|
||||
tag="div"
|
||||
class="flex min-h-[200px] items-center justify-center w-full cursor-grab select-none will-change-transform [transform-style:preserve-3d] active:cursor-grabbing"
|
||||
:style="trackStyle"
|
||||
:animate="animateProps"
|
||||
:transition="springTransition"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@mousedown="handleMouseDown"
|
||||
>
|
||||
<div
|
||||
v-for="(url, i) in displayImages"
|
||||
:key="`gallery-${i}`"
|
||||
:style="getItemStyle(i)"
|
||||
class="absolute flex items-center justify-center px-[8%] [backface-visibility:hidden] will-change-transform pointer-events-none"
|
||||
>
|
||||
<img
|
||||
:src="url"
|
||||
alt="gallery"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="pointer-events-auto h-[120px] w-[300px] rounded-[15px] border-[3px] border-white object-cover transition-transform duration-300 ease-in-out will-change-transform hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
</Motion>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue';
|
||||
import { Motion } from 'motion-v';
|
||||
|
||||
interface RollingGalleryProps {
|
||||
autoplay?: boolean;
|
||||
pauseOnHover?: boolean;
|
||||
images?: string[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<RollingGalleryProps>(), {
|
||||
autoplay: false,
|
||||
pauseOnHover: false,
|
||||
images: () => []
|
||||
});
|
||||
|
||||
const DEFAULT_IMAGES = shallowRef([
|
||||
"https://images.unsplash.com/photo-1528181304800-259b08848526?q=80&w=3870&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
|
||||
"https://images.unsplash.com/photo-1506665531195-3566af2b4dfa?q=80&w=3870&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
|
||||
"https://images.unsplash.com/photo-1520250497591-112f2f40a3f4?q=80&w=3456&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
|
||||
"https://images.unsplash.com/photo-1495103033382-fe343886b671?q=80&w=3870&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
|
||||
"https://images.unsplash.com/photo-1506781961370-37a89d6b3095?q=80&w=3264&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
|
||||
"https://images.unsplash.com/photo-1599576838688-8a6c11263108?q=80&w=3870&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
|
||||
"https://images.unsplash.com/photo-1494094892896-7f14a4433b7a?q=80&w=3870&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
|
||||
"https://plus.unsplash.com/premium_photo-1664910706524-e783eed89e71?q=80&w=3869&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
|
||||
"https://images.unsplash.com/photo-1503788311183-fa3bf9c4bc32?q=80&w=3870&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
|
||||
"https://images.unsplash.com/photo-1585970480901-90d6bb2a48b5?q=80&w=3774&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
|
||||
])
|
||||
|
||||
const isScreenSizeSm = ref(false);
|
||||
const rotateYValue = ref(0);
|
||||
const autoplayInterval = ref<number | null>(null);
|
||||
const autoplayTimeout = ref<number | null>(null);
|
||||
const isDragging = ref(false);
|
||||
const isHovered = ref(false);
|
||||
const dragStartX = ref(0);
|
||||
const dragStartRotation = ref(0);
|
||||
|
||||
const displayImages = computed(() => {
|
||||
const sourceImages = props.images.length > 0 ? props.images : DEFAULT_IMAGES.value;
|
||||
const maxImages = REFERENCE_FACE_COUNT_SPACING;
|
||||
|
||||
if (sourceImages.length >= maxImages) {
|
||||
return sourceImages;
|
||||
}
|
||||
|
||||
const repeatedImages = [];
|
||||
const repetitions = Math.ceil(maxImages / sourceImages.length);
|
||||
|
||||
for (let i = 0; i < repetitions; i++) {
|
||||
repeatedImages.push(...sourceImages);
|
||||
}
|
||||
|
||||
return repeatedImages.slice(0, maxImages);
|
||||
});
|
||||
|
||||
const cylinderWidth = computed(() => (isScreenSizeSm.value ? 1100 : 1800));
|
||||
const faceWidth = computed(() => {
|
||||
return (cylinderWidth.value / REFERENCE_FACE_COUNT_SIZING) * 1.5;
|
||||
});
|
||||
const radius = computed(() => cylinderWidth.value / (2 * Math.PI));
|
||||
|
||||
const DRAG_FACTOR = Object.freeze(0.15);
|
||||
const MOMENTUM_FACTOR = Object.freeze(0.05);
|
||||
const AUTOPLAY_INTERVAL = Object.freeze(2000);
|
||||
const DRAG_RESTART_DELAY = Object.freeze(1500);
|
||||
const HOVER_RESTART_DELAY = Object.freeze(100);
|
||||
const HOVER_DEBOUNCE_DELAY = Object.freeze(50);
|
||||
const REFERENCE_FACE_COUNT_SPACING = Object.freeze(10);
|
||||
const REFERENCE_FACE_COUNT_SIZING = Object.freeze(10);
|
||||
|
||||
const trackStyle = computed(() => ({
|
||||
width: `${cylinderWidth.value}px`,
|
||||
transformStyle: 'preserve-3d' as const
|
||||
}));
|
||||
|
||||
const animateProps = computed(() => ({
|
||||
rotateY: rotateYValue.value
|
||||
}));
|
||||
|
||||
const springTransition = computed(() => {
|
||||
if (isDragging.value) {
|
||||
return { duration: 0 };
|
||||
} else {
|
||||
return {
|
||||
duration: 0.8,
|
||||
ease: 'easeOut' as const
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const styleCache = new Map<string, { width: string; transform: string }>();
|
||||
|
||||
const getItemStyle = (index: number) => {
|
||||
const cacheKey = `${index}-${faceWidth.value}-${radius.value}`;
|
||||
|
||||
if (styleCache.has(cacheKey)) {
|
||||
return styleCache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
const style = {
|
||||
width: `${faceWidth.value}px`,
|
||||
transform: `rotateY(${index * (360 / REFERENCE_FACE_COUNT_SPACING)}deg) translateZ(${radius.value}px)`
|
||||
};
|
||||
|
||||
if (styleCache.size > 50) {
|
||||
styleCache.clear();
|
||||
}
|
||||
|
||||
styleCache.set(cacheKey, style);
|
||||
return style;
|
||||
};
|
||||
|
||||
let resizeTimeout: number | null = null;
|
||||
let hoverTimeout: number | null = null;
|
||||
|
||||
function checkScreenSize() {
|
||||
isScreenSizeSm.value = window.innerWidth <= 640;
|
||||
}
|
||||
|
||||
function throttledResize() {
|
||||
if (resizeTimeout) return;
|
||||
resizeTimeout = setTimeout(() => {
|
||||
checkScreenSize();
|
||||
resizeTimeout = null;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function handleMouseDown(event: MouseEvent) {
|
||||
isDragging.value = true;
|
||||
dragStartX.value = event.clientX;
|
||||
dragStartRotation.value = rotateYValue.value;
|
||||
|
||||
stopAutoplay();
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove, { passive: true });
|
||||
document.addEventListener('mouseup', handleMouseUp, { passive: true });
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function handleMouseMove(event: MouseEvent) {
|
||||
if (!isDragging.value) return;
|
||||
|
||||
const deltaX = event.clientX - dragStartX.value;
|
||||
const rotationDelta = deltaX * DRAG_FACTOR;
|
||||
rotateYValue.value = dragStartRotation.value + rotationDelta;
|
||||
}
|
||||
|
||||
function handleMouseUp(event: MouseEvent) {
|
||||
if (!isDragging.value) return;
|
||||
|
||||
isDragging.value = false;
|
||||
|
||||
const deltaX = event.clientX - dragStartX.value;
|
||||
const velocity = deltaX * MOMENTUM_FACTOR;
|
||||
rotateYValue.value += velocity;
|
||||
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
|
||||
stopAutoplay();
|
||||
|
||||
if (props.autoplay) {
|
||||
if (props.pauseOnHover && isHovered.value) {
|
||||
return;
|
||||
} else {
|
||||
autoplayTimeout.value = setTimeout(() => {
|
||||
if (!isDragging.value && (!props.pauseOnHover || !isHovered.value)) {
|
||||
startAutoplay();
|
||||
}
|
||||
}, DRAG_RESTART_DELAY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startAutoplay() {
|
||||
if (!props.autoplay || isDragging.value || (props.pauseOnHover && isHovered.value)) return;
|
||||
|
||||
stopAutoplay();
|
||||
|
||||
autoplayInterval.value = setInterval(() => {
|
||||
if (!isDragging.value && (!props.pauseOnHover || !isHovered.value)) {
|
||||
rotateYValue.value -= 360 / REFERENCE_FACE_COUNT_SPACING;
|
||||
}
|
||||
}, AUTOPLAY_INTERVAL);
|
||||
}
|
||||
|
||||
function stopAutoplay() {
|
||||
if (autoplayInterval.value) {
|
||||
clearInterval(autoplayInterval.value);
|
||||
autoplayInterval.value = null;
|
||||
}
|
||||
if (autoplayTimeout.value) {
|
||||
clearTimeout(autoplayTimeout.value);
|
||||
autoplayTimeout.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseEnter() {
|
||||
if (hoverTimeout) {
|
||||
clearTimeout(hoverTimeout);
|
||||
hoverTimeout = null;
|
||||
}
|
||||
|
||||
hoverTimeout = setTimeout(() => {
|
||||
isHovered.value = true;
|
||||
|
||||
if (props.autoplay && props.pauseOnHover && !isDragging.value) {
|
||||
stopAutoplay();
|
||||
}
|
||||
}, HOVER_DEBOUNCE_DELAY);
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
if (hoverTimeout) {
|
||||
clearTimeout(hoverTimeout);
|
||||
hoverTimeout = null;
|
||||
}
|
||||
|
||||
hoverTimeout = setTimeout(() => {
|
||||
isHovered.value = false;
|
||||
|
||||
if (props.autoplay && props.pauseOnHover && !isDragging.value) {
|
||||
stopAutoplay();
|
||||
autoplayTimeout.value = setTimeout(() => {
|
||||
if (props.autoplay && !isDragging.value && !isHovered.value) {
|
||||
startAutoplay();
|
||||
}
|
||||
}, HOVER_RESTART_DELAY);
|
||||
}
|
||||
}, HOVER_DEBOUNCE_DELAY);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkScreenSize();
|
||||
window.addEventListener('resize', throttledResize, { passive: true });
|
||||
|
||||
if (props.autoplay) {
|
||||
startAutoplay();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', throttledResize);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
stopAutoplay();
|
||||
if (resizeTimeout) {
|
||||
clearTimeout(resizeTimeout);
|
||||
}
|
||||
if (hoverTimeout) {
|
||||
clearTimeout(hoverTimeout);
|
||||
hoverTimeout = null;
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.autoplay,
|
||||
newVal => {
|
||||
stopAutoplay();
|
||||
if (newVal && !isDragging.value && (!props.pauseOnHover || !isHovered.value)) {
|
||||
autoplayTimeout.value = setTimeout(() => {
|
||||
if (!isDragging.value && (!props.pauseOnHover || !isHovered.value)) {
|
||||
startAutoplay();
|
||||
}
|
||||
}, HOVER_RESTART_DELAY);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.pauseOnHover,
|
||||
() => {
|
||||
if (props.autoplay) {
|
||||
stopAutoplay();
|
||||
if (!isDragging.value && (!props.pauseOnHover || !isHovered.value)) {
|
||||
startAutoplay();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
292
src/content/Components/ScrollStack/ScrollStack.vue
Normal file
292
src/content/Components/ScrollStack/ScrollStack.vue
Normal file
@@ -0,0 +1,292 @@
|
||||
<script setup lang="ts">
|
||||
import Lenis from 'lenis';
|
||||
import { defineComponent, h, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
|
||||
|
||||
interface CardTransform {
|
||||
translateY: number;
|
||||
scale: number;
|
||||
rotation: number;
|
||||
blur: number;
|
||||
}
|
||||
|
||||
interface ScrollStackProps {
|
||||
className?: string;
|
||||
itemDistance?: number;
|
||||
itemScale?: number;
|
||||
itemStackDistance?: number;
|
||||
stackPosition?: string;
|
||||
scaleEndPosition?: string;
|
||||
baseScale?: number;
|
||||
scaleDuration?: number;
|
||||
rotationAmount?: number;
|
||||
blurAmount?: number;
|
||||
onStackComplete?: () => void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ScrollStackProps>(), {
|
||||
className: '',
|
||||
itemDistance: 100,
|
||||
itemScale: 0.03,
|
||||
itemStackDistance: 30,
|
||||
stackPosition: '20%',
|
||||
scaleEndPosition: '10%',
|
||||
baseScale: 0.85,
|
||||
scaleDuration: 0.5,
|
||||
rotationAmount: 0,
|
||||
blurAmount: 0
|
||||
});
|
||||
|
||||
const scrollerRef = useTemplateRef('scrollerRef');
|
||||
const stackCompletedRef = ref(false);
|
||||
const animationFrameRef = ref<number | null>(null);
|
||||
const lenisRef = ref<Lenis | null>(null);
|
||||
const cardsRef = ref<HTMLElement[]>([]);
|
||||
const lastTransformsRef = ref(new Map<number, CardTransform>());
|
||||
const isUpdatingRef = ref(false);
|
||||
|
||||
const calculateProgress = (scrollTop: number, start: number, end: number) => {
|
||||
if (scrollTop < start) return 0;
|
||||
if (scrollTop > end) return 1;
|
||||
return (scrollTop - start) / (end - start);
|
||||
};
|
||||
|
||||
const parsePercentage = (value: string | number, containerHeight: number) => {
|
||||
if (typeof value === 'string' && value.includes('%')) {
|
||||
return (parseFloat(value) / 100) * containerHeight;
|
||||
}
|
||||
return parseFloat(value as string);
|
||||
};
|
||||
|
||||
const updateCardTransforms = () => {
|
||||
const scroller = scrollerRef.value;
|
||||
if (!scroller || !cardsRef.value.length || isUpdatingRef.value) return;
|
||||
|
||||
isUpdatingRef.value = true;
|
||||
|
||||
const scrollTop = scroller.scrollTop;
|
||||
const containerHeight = scroller.clientHeight;
|
||||
const stackPositionPx = parsePercentage(props.stackPosition, containerHeight);
|
||||
const scaleEndPositionPx = parsePercentage(props.scaleEndPosition, containerHeight);
|
||||
const endElement = scroller.querySelector('.scroll-stack-end') as HTMLElement;
|
||||
const endElementTop = endElement ? endElement.offsetTop : 0;
|
||||
|
||||
cardsRef.value.forEach((card, i) => {
|
||||
if (!card) return;
|
||||
|
||||
const cardTop = card.offsetTop;
|
||||
const triggerStart = cardTop - stackPositionPx - props.itemStackDistance * i;
|
||||
const triggerEnd = cardTop - scaleEndPositionPx;
|
||||
const pinStart = cardTop - stackPositionPx - props.itemStackDistance * i;
|
||||
const pinEnd = endElementTop - containerHeight / 2;
|
||||
|
||||
const scaleProgress = calculateProgress(scrollTop, triggerStart, triggerEnd);
|
||||
const targetScale = props.baseScale + i * props.itemScale;
|
||||
const scale = 1 - scaleProgress * (1 - targetScale);
|
||||
const rotation = props.rotationAmount ? i * props.rotationAmount * scaleProgress : 0;
|
||||
|
||||
let blur = 0;
|
||||
if (props.blurAmount) {
|
||||
let topCardIndex = 0;
|
||||
for (let j = 0; j < cardsRef.value.length; j++) {
|
||||
const jCardTop = cardsRef.value[j].offsetTop;
|
||||
const jTriggerStart = jCardTop - stackPositionPx - props.itemStackDistance * j;
|
||||
if (scrollTop >= jTriggerStart) {
|
||||
topCardIndex = j;
|
||||
}
|
||||
}
|
||||
|
||||
if (i < topCardIndex) {
|
||||
const depthInStack = topCardIndex - i;
|
||||
blur = Math.max(0, depthInStack * props.blurAmount);
|
||||
}
|
||||
}
|
||||
|
||||
let translateY = 0;
|
||||
const isPinned = scrollTop >= pinStart && scrollTop <= pinEnd;
|
||||
|
||||
if (isPinned) {
|
||||
translateY = scrollTop - cardTop + stackPositionPx + props.itemStackDistance * i;
|
||||
} else if (scrollTop > pinEnd) {
|
||||
translateY = pinEnd - cardTop + stackPositionPx + props.itemStackDistance * i;
|
||||
}
|
||||
|
||||
const newTransform = {
|
||||
translateY: Math.round(translateY * 100) / 100,
|
||||
scale: Math.round(scale * 1000) / 1000,
|
||||
rotation: Math.round(rotation * 100) / 100,
|
||||
blur: Math.round(blur * 100) / 100
|
||||
};
|
||||
|
||||
const lastTransform = lastTransformsRef.value.get(i);
|
||||
const hasChanged =
|
||||
!lastTransform ||
|
||||
Math.abs(lastTransform.translateY - newTransform.translateY) > 0.1 ||
|
||||
Math.abs(lastTransform.scale - newTransform.scale) > 0.001 ||
|
||||
Math.abs(lastTransform.rotation - newTransform.rotation) > 0.1 ||
|
||||
Math.abs(lastTransform.blur - newTransform.blur) > 0.1;
|
||||
|
||||
if (hasChanged) {
|
||||
const transform = `translate3d(0, ${newTransform.translateY}px, 0) scale(${newTransform.scale}) rotate(${newTransform.rotation}deg)`;
|
||||
const filter = newTransform.blur > 0 ? `blur(${newTransform.blur}px)` : '';
|
||||
|
||||
card.style.transform = transform;
|
||||
card.style.filter = filter;
|
||||
|
||||
lastTransformsRef.value.set(i, newTransform);
|
||||
}
|
||||
|
||||
if (i === cardsRef.value.length - 1) {
|
||||
const isInView = scrollTop >= pinStart && scrollTop <= pinEnd;
|
||||
if (isInView && !stackCompletedRef.value) {
|
||||
stackCompletedRef.value = true;
|
||||
props.onStackComplete?.();
|
||||
} else if (!isInView && stackCompletedRef.value) {
|
||||
stackCompletedRef.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
isUpdatingRef.value = false;
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
updateCardTransforms();
|
||||
};
|
||||
|
||||
const setupLenis = () => {
|
||||
const scroller = scrollerRef.value;
|
||||
if (!scroller) return;
|
||||
|
||||
const lenis = new Lenis({
|
||||
wrapper: scroller,
|
||||
content: scroller.querySelector('.scroll-stack-inner') as HTMLElement,
|
||||
duration: 1.2,
|
||||
easing: t => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
||||
smoothWheel: true,
|
||||
touchMultiplier: 2,
|
||||
infinite: false,
|
||||
gestureOrientation: 'vertical',
|
||||
wheelMultiplier: 1,
|
||||
lerp: 0.1,
|
||||
syncTouch: true,
|
||||
syncTouchLerp: 0.075
|
||||
});
|
||||
|
||||
lenis.on('scroll', handleScroll);
|
||||
|
||||
const raf = (time: number) => {
|
||||
lenis.raf(time);
|
||||
animationFrameRef.value = requestAnimationFrame(raf);
|
||||
};
|
||||
animationFrameRef.value = requestAnimationFrame(raf);
|
||||
|
||||
lenisRef.value = lenis;
|
||||
return lenis;
|
||||
};
|
||||
|
||||
let cleanup: (() => void) | null = null;
|
||||
const setup = () => {
|
||||
const scroller = scrollerRef.value;
|
||||
if (!scroller) return;
|
||||
|
||||
const cards = Array.from(scroller.querySelectorAll('.scroll-stack-card')) as HTMLElement[];
|
||||
cardsRef.value = cards;
|
||||
const transformsCache = lastTransformsRef.value;
|
||||
|
||||
cards.forEach((card, i) => {
|
||||
if (i < cards.length - 1) {
|
||||
card.style.marginBottom = `${props.itemDistance}px`;
|
||||
}
|
||||
card.style.willChange = 'transform, filter';
|
||||
card.style.transformOrigin = 'top center';
|
||||
card.style.backfaceVisibility = 'hidden';
|
||||
card.style.transform = 'translateZ(0)';
|
||||
card.style.webkitTransform = 'translateZ(0)';
|
||||
card.style.perspective = '1000px';
|
||||
card.style.webkitPerspective = '1000px';
|
||||
});
|
||||
|
||||
setupLenis();
|
||||
|
||||
updateCardTransforms();
|
||||
|
||||
cleanup = () => {
|
||||
if (animationFrameRef.value) {
|
||||
cancelAnimationFrame(animationFrameRef.value);
|
||||
}
|
||||
if (lenisRef.value) {
|
||||
lenisRef.value.destroy();
|
||||
}
|
||||
stackCompletedRef.value = false;
|
||||
cardsRef.value = [];
|
||||
transformsCache.clear();
|
||||
isUpdatingRef.value = false;
|
||||
};
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
setup();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanup?.();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props,
|
||||
() => {
|
||||
cleanup?.();
|
||||
setup();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export const ScrollStackItem = defineComponent({
|
||||
name: 'ScrollStackItem',
|
||||
props: {
|
||||
itemClassName: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class:
|
||||
`scroll-stack-card relative w-full h-80 my-8 p-12 rounded-[40px] shadow-[0_0_30px_rgba(0,0,0,0.1)] box-border origin-top will-change-transform ${props.itemClassName}`.trim(),
|
||||
style: {
|
||||
backfaceVisibility: 'hidden',
|
||||
transformStyle: 'preserve-3d'
|
||||
}
|
||||
},
|
||||
slots.default?.()
|
||||
);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="scrollerRef"
|
||||
:class="['relative w-full h-full overflow-y-auto overflow-x-visible', className]"
|
||||
:style="{
|
||||
overscrollBehavior: 'contain',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
scrollBehavior: 'smooth',
|
||||
WebkitTransform: 'translateZ(0)',
|
||||
transform: 'translateZ(0)',
|
||||
willChange: 'scroll-position'
|
||||
}"
|
||||
>
|
||||
<div class="px-20 pt-[20vh] pb-[50rem] min-h-screen scroll-stack-inner">
|
||||
<slot />
|
||||
<!-- Spacer so the last pin can release cleanly -->
|
||||
<div class="w-full h-px scroll-stack-end" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -566,3 +566,90 @@ div:has(> .props-table) {
|
||||
order: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-stack-card-demo {
|
||||
font-size: clamp(1.5rem, 4vw, 3rem);
|
||||
font-weight: 900;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.scroll-stack-card-demo .stack-img-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 150px;
|
||||
border-radius: 1.5rem;
|
||||
border: 10px solid #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: clamp(4rem, 8vw, 8rem);
|
||||
}
|
||||
|
||||
.scroll-stack-demo-container .scroll-stack-inner {
|
||||
padding: 20vh 2rem 50rem;
|
||||
}
|
||||
|
||||
.ssc-demo-1 {
|
||||
background-color: #35724d;
|
||||
}
|
||||
|
||||
.ssc-demo-2 {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
.ssc-demo-3 {
|
||||
background-color: #35724d;
|
||||
}
|
||||
|
||||
.ssc-demo-4 {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
.ssc-demo-5 {
|
||||
background-color: #35724d;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 1240px) {
|
||||
.scroll-stack-card-demo {
|
||||
flex-direction: row;
|
||||
gap: 2rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.scroll-stack-demo-container .scroll-stack-inner {
|
||||
padding: 20vh 5rem 50rem;
|
||||
}
|
||||
|
||||
.scroll-stack-card-demo .stack-img-container {
|
||||
width: 50%;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.scroll-stack-card-demo h3 {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 480px) {
|
||||
.scroll-stack-card-demo {
|
||||
font-size: 1rem;
|
||||
padding: 0.2rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.scroll-stack-card-demo .stack-img-container {
|
||||
border-width: 5px;
|
||||
border-radius: 1rem;
|
||||
min-height: 120px;
|
||||
font-size: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
81
src/demo/Components/RollingGalleryDemo.vue
Normal file
81
src/demo/Components/RollingGalleryDemo.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<TabbedLayout>
|
||||
<template #preview>
|
||||
<div class="demo-container relative min-h-[500px] overflow-hidden">
|
||||
<div class="flex h-full max-w-[600px] flex-col items-center justify-center">
|
||||
<h2
|
||||
class="absolute top-4 mt-6 whitespace-nowrap text-center font-black text-2xl text-white md:top-4 md:text-5xl"
|
||||
>
|
||||
Your trip to Thailand.
|
||||
</h2>
|
||||
<RollingGallery
|
||||
:autoplay="autoplay"
|
||||
:pause-on-hover="pauseOnHover"
|
||||
:images="customImages.length > 0 ? customImages : undefined"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Customize>
|
||||
<PreviewSwitch
|
||||
title="Autoplay"
|
||||
v-model="autoplay"
|
||||
/>
|
||||
|
||||
<PreviewSwitch
|
||||
title="Pause on Hover"
|
||||
v-model="pauseOnHover"
|
||||
/>
|
||||
</Customize>
|
||||
|
||||
<PropTable :data="propData" />
|
||||
<Dependencies :dependency-list="['motion-v']" />
|
||||
</template>
|
||||
|
||||
<template #code>
|
||||
<CodeExample :code-object="rollingGallery" />
|
||||
</template>
|
||||
|
||||
<template #cli>
|
||||
<CliInstallation :command="rollingGallery.cli" />
|
||||
</template>
|
||||
</TabbedLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import TabbedLayout from '@/components/common/TabbedLayout.vue'
|
||||
import PropTable from '@/components/common/PropTable.vue'
|
||||
import Dependencies from '@/components/code/Dependencies.vue'
|
||||
import CliInstallation from '@/components/code/CliInstallation.vue'
|
||||
import CodeExample from '@/components/code/CodeExample.vue'
|
||||
import Customize from '@/components/common/Customize.vue'
|
||||
import PreviewSwitch from '@/components/common/PreviewSwitch.vue'
|
||||
import RollingGallery from '@/content/Components/RollingGallery/RollingGallery.vue'
|
||||
import { rollingGallery } from '@/constants/code/Components/rollingGalleryCode'
|
||||
|
||||
const autoplay = ref(true)
|
||||
const pauseOnHover = ref(true)
|
||||
const customImages = ref<string[]>([])
|
||||
|
||||
const propData = [
|
||||
{
|
||||
name: 'autoplay',
|
||||
type: 'boolean',
|
||||
default: 'false',
|
||||
description: 'Controls the autoplay toggle of the carousel. When turned on, it rotates and loops infinitely.',
|
||||
},
|
||||
{
|
||||
name: 'pauseOnHover',
|
||||
type: 'boolean',
|
||||
default: 'false',
|
||||
description: 'Allows the carousel to be paused on hover when autoplay is turned on.',
|
||||
},
|
||||
{
|
||||
name: 'images',
|
||||
type: 'string[]',
|
||||
default: '[]',
|
||||
description: 'Array of image URLs to display in the gallery.',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
216
src/demo/Components/ScrollStackDemo.vue
Normal file
216
src/demo/Components/ScrollStackDemo.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<TabbedLayout>
|
||||
<template #preview>
|
||||
<div class="relative h-[500px] overflow-hidden demo-container">
|
||||
<RefreshButton
|
||||
@refresh="
|
||||
() => {
|
||||
isCompleted = false;
|
||||
forceRerender();
|
||||
}
|
||||
"
|
||||
/>
|
||||
<p
|
||||
class="top-[25%] left-[50%] absolute font-black text-[#333] text-[clamp(2rem,4vw,3rem)] text-center transition-all -translate-x-1/2 -translate-y-1/2 duration-300 ease-in-out pointer-events-none transform"
|
||||
>
|
||||
{{ isCompleted ? 'Stack Completed!' : 'Scroll Down' }}
|
||||
</p>
|
||||
|
||||
<ScrollStack
|
||||
:key="rerenderKey"
|
||||
:item-distance="itemDistance"
|
||||
className="scroll-stack-demo-container"
|
||||
:item-stack-distance="itemStackDistance"
|
||||
:stack-position="stackPosition"
|
||||
:base-scale="baseScale"
|
||||
:rotation-amount="rotationAmount"
|
||||
:blur-amount="blurAmount"
|
||||
@stackComplete="handleStackComplete"
|
||||
>
|
||||
<ScrollStackItem itemClassName="scroll-stack-card-demo ssc-demo-1">
|
||||
<h3>Text Animations</h3>
|
||||
|
||||
<div className="stack-img-container">
|
||||
<i class="pi-align-left pi" style="font-size: 120px"></i>
|
||||
</div>
|
||||
</ScrollStackItem>
|
||||
|
||||
<ScrollStackItem itemClassName="scroll-stack-card-demo ssc-demo-2">
|
||||
<h3>Animations</h3>
|
||||
|
||||
<div className="stack-img-container">
|
||||
<i class="pi pi-play" style="font-size: 120px"></i>
|
||||
</div>
|
||||
</ScrollStackItem>
|
||||
|
||||
<ScrollStackItem itemClassName="scroll-stack-card-demo ssc-demo-3">
|
||||
<h3>Components</h3>
|
||||
|
||||
<div className="stack-img-container">
|
||||
<i class="pi pi-sliders-h" style="font-size: 120px"></i>
|
||||
</div>
|
||||
</ScrollStackItem>
|
||||
|
||||
<ScrollStackItem itemClassName="scroll-stack-card-demo ssc-demo-4">
|
||||
<h3>Backgrounds</h3>
|
||||
|
||||
<div className="stack-img-container">
|
||||
<i class="pi pi-image" style="font-size: 120px"></i>
|
||||
</div>
|
||||
</ScrollStackItem>
|
||||
|
||||
<ScrollStackItem itemClassName="scroll-stack-card-demo ssc-demo-5">
|
||||
<h3>All on Vue Bits!</h3>
|
||||
</ScrollStackItem>
|
||||
</ScrollStack>
|
||||
</div>
|
||||
|
||||
<Customize>
|
||||
<PreviewSlider title="Item Distance" v-model="itemDistance" :min="0" :max="1000" :step="10" value-unit="px" />
|
||||
<PreviewSlider
|
||||
title="Stack Distance"
|
||||
v-model="itemStackDistance"
|
||||
:min="0"
|
||||
:max="40"
|
||||
:step="5"
|
||||
value-unit="px"
|
||||
/>
|
||||
<PreviewSelect title="Stack Position" v-model="stackPosition" :options="stackPositionOptions" />
|
||||
<PreviewSlider title="Base Scale" v-model="baseScale" :min="0.5" :max="1.0" :step="0.05" />
|
||||
<PreviewSlider title="Rotation Amount" v-model="rotationAmount" :min="0" :max="1" :step="0.1" value-unit="°" />
|
||||
<PreviewSlider title="Blur Amount" v-model="blurAmount" :min="0" :max="10" :step="0.5" value-unit="px" />
|
||||
</Customize>
|
||||
|
||||
<PropTable :data="propData" />
|
||||
<Dependencies :dependency-list="['lenis']" />
|
||||
</template>
|
||||
|
||||
<template #code>
|
||||
<CodeExample :code-object="scrollStack" />
|
||||
</template>
|
||||
|
||||
<template #cli>
|
||||
<CliInstallation :command="scrollStack.cli" />
|
||||
</template>
|
||||
</TabbedLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import PreviewSelect from '@/components/common/PreviewSelect.vue';
|
||||
import { useForceRerender } from '@/composables/useForceRerender';
|
||||
import { ref } from 'vue';
|
||||
import CliInstallation from '../../components/code/CliInstallation.vue';
|
||||
import CodeExample from '../../components/code/CodeExample.vue';
|
||||
import Dependencies from '../../components/code/Dependencies.vue';
|
||||
import Customize from '../../components/common/Customize.vue';
|
||||
import PreviewSlider from '../../components/common/PreviewSlider.vue';
|
||||
import PropTable from '../../components/common/PropTable.vue';
|
||||
import RefreshButton from '../../components/common/RefreshButton.vue';
|
||||
import TabbedLayout from '../../components/common/TabbedLayout.vue';
|
||||
import { scrollStack } from '../../constants/code/Components/scrollStackCode';
|
||||
import ScrollStack, { ScrollStackItem } from '../../content/Components/ScrollStack/ScrollStack.vue';
|
||||
|
||||
const { rerenderKey, forceRerender } = useForceRerender();
|
||||
|
||||
const isCompleted = ref(false);
|
||||
const itemDistance = ref(200);
|
||||
const itemStackDistance = ref(30);
|
||||
const baseScale = ref(0.85);
|
||||
const rotationAmount = ref(0);
|
||||
const blurAmount = ref(0);
|
||||
const stackPosition = ref('20%');
|
||||
|
||||
const handleStackComplete = () => {
|
||||
isCompleted.value = true;
|
||||
};
|
||||
|
||||
const stackPositionOptions = [
|
||||
{ value: '10%', label: '10%' },
|
||||
{ value: '15%', label: '15%' },
|
||||
{ value: '20%', label: '20%' },
|
||||
{ value: '25%', label: '25%' },
|
||||
{ value: '30%', label: '30%' },
|
||||
{ value: '35%', label: '35%' }
|
||||
];
|
||||
|
||||
const propData = [
|
||||
{
|
||||
name: 'children',
|
||||
type: 'ReactNode',
|
||||
default: 'required',
|
||||
description: 'The content to be displayed in the scroll stack. Should contain ScrollStackItem components.'
|
||||
},
|
||||
{
|
||||
name: 'className',
|
||||
type: 'string',
|
||||
default: '""',
|
||||
description: 'Additional CSS classes to apply to the scroll stack container.'
|
||||
},
|
||||
{
|
||||
name: 'itemDistance',
|
||||
type: 'number',
|
||||
default: '100',
|
||||
description: 'Distance between stacked items in pixels.'
|
||||
},
|
||||
{
|
||||
name: 'itemScale',
|
||||
type: 'number',
|
||||
default: '0.03',
|
||||
description: 'Scale increment for each stacked item.'
|
||||
},
|
||||
{
|
||||
name: 'itemStackDistance',
|
||||
type: 'number',
|
||||
default: '30',
|
||||
description: 'Distance between items when they start stacking.'
|
||||
},
|
||||
{
|
||||
name: 'stackPosition',
|
||||
type: 'string',
|
||||
default: '"20%"',
|
||||
description: 'Position where the stacking effect begins as a percentage of viewport height.'
|
||||
},
|
||||
{
|
||||
name: 'scaleEndPosition',
|
||||
type: 'string',
|
||||
default: '"10%"',
|
||||
description: 'Position where the scaling effect ends as a percentage of viewport height.'
|
||||
},
|
||||
{
|
||||
name: 'baseScale',
|
||||
type: 'number',
|
||||
default: '0.85',
|
||||
description: 'Base scale value for the first item in the stack.'
|
||||
},
|
||||
{
|
||||
name: 'scaleDuration',
|
||||
type: 'number',
|
||||
default: '0.5',
|
||||
description: 'Duration of the scaling animation in seconds.'
|
||||
},
|
||||
{
|
||||
name: 'rotationAmount',
|
||||
type: 'number',
|
||||
default: '0',
|
||||
description: 'Rotation amount for each item in degrees.'
|
||||
},
|
||||
{
|
||||
name: 'blurAmount',
|
||||
type: 'number',
|
||||
default: '0',
|
||||
description: 'Blur amount for items that are further back in the stack.'
|
||||
},
|
||||
{
|
||||
name: 'onStackComplete',
|
||||
type: 'function',
|
||||
default: 'undefined',
|
||||
description: 'Callback function called when the stack animation is complete.'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-container {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user