mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 22:49:31 -07:00
1 line
12 KiB
JSON
1 line
12 KiB
JSON
{"name":"LogoLoop","title":"LogoLoop","description":"Continuously looping marquee of brand or tech logos with seamless repeat and hover pause.","type":"registry:component","add":"when-added","files":[{"type":"registry:component","role":"file","content":"<template>\n <div\n ref=\"containerRef\"\n :class=\"rootClasses\"\n :style=\"containerStyle\"\n role=\"region\"\n :aria-label=\"ariaLabel\"\n @mouseenter=\"handleMouseEnter\"\n @mouseleave=\"handleMouseLeave\"\n >\n <template v-if=\"fadeOut\">\n <div\n aria-hidden\n :class=\"[\n 'pointer-events-none absolute inset-y-0 left-0 z-[1]',\n 'w-[clamp(24px,8%,120px)]',\n 'bg-[linear-gradient(to_right,var(--logoloop-fadeColor,var(--logoloop-fadeColorAuto))_0%,rgba(0,0,0,0)_100%)]'\n ]\"\n />\n <div\n aria-hidden\n :class=\"[\n 'pointer-events-none absolute inset-y-0 right-0 z-[1]',\n 'w-[clamp(24px,8%,120px)]',\n 'bg-[linear-gradient(to_left,var(--logoloop-fadeColor,var(--logoloop-fadeColorAuto))_0%,rgba(0,0,0,0)_100%)]'\n ]\"\n />\n </template>\n\n <div ref=\"trackRef\" :class=\"['flex w-max will-change-transform select-none', 'motion-reduce:transform-none']\">\n <ul\n v-for=\"copyIndex in copyCount\"\n :key=\"`copy-${copyIndex - 1}`\"\n class=\"flex items-center\"\n role=\"list\"\n :aria-hidden=\"copyIndex > 1\"\n :ref=\"\n el => {\n if (copyIndex === 1) seqRef = el as HTMLUListElement;\n }\n \"\n >\n <li\n v-for=\"(item, itemIndex) in logos\"\n :key=\"`${copyIndex - 1}-${itemIndex}`\"\n :class=\"[\n 'flex-none mr-[var(--logoloop-gap)] text-[length:var(--logoloop-logoHeight)] leading-[1]',\n scaleOnHover && 'overflow-visible group/item'\n ]\"\n role=\"listitem\"\n >\n <a\n v-if=\"item.href\"\n :class=\"[\n 'inline-flex items-center no-underline rounded',\n 'transition-opacity duration-200 ease-linear',\n 'hover:opacity-80',\n 'focus-visible:outline focus-visible:outline-current focus-visible:outline-offset-2'\n ]\"\n :href=\"item.href\"\n :aria-label=\"getItemAriaLabel(item) || 'logo link'\"\n target=\"_blank\"\n rel=\"noreferrer noopener\"\n >\n <LogoContent :item=\"item\" :scale-on-hover=\"scaleOnHover\" />\n </a>\n <LogoContent v-else :item=\"item\" :scale-on-hover=\"scaleOnHover\" />\n </li>\n </ul>\n </div>\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed, nextTick, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';\n\nexport type LogoItemNode = {\n node: string;\n href?: string;\n title?: string;\n ariaLabel?: string;\n};\n\nexport type LogoItemImage = {\n src: string;\n alt?: string;\n href?: string;\n title?: string;\n srcSet?: string;\n sizes?: string;\n width?: number;\n height?: number;\n};\n\nexport type LogoItem = LogoItemNode | LogoItemImage;\n\nexport interface LogoLoopProps {\n logos: LogoItem[];\n speed?: number;\n direction?: 'left' | 'right';\n width?: number | string;\n logoHeight?: number;\n gap?: number;\n pauseOnHover?: boolean;\n fadeOut?: boolean;\n fadeOutColor?: string;\n scaleOnHover?: boolean;\n ariaLabel?: string;\n className?: string;\n style?: string;\n}\n\nconst ANIMATION_CONFIG = {\n SMOOTH_TAU: 0.25,\n MIN_COPIES: 2,\n COPY_HEADROOM: 2\n} as const;\n\nconst props = withDefaults(defineProps<LogoLoopProps>(), {\n speed: 120,\n direction: 'left',\n width: '100%',\n logoHeight: 60,\n gap: 32,\n pauseOnHover: true,\n fadeOut: false,\n scaleOnHover: false,\n ariaLabel: 'Partner logos'\n});\n\nconst containerRef = useTemplateRef('containerRef');\nconst trackRef = useTemplateRef('trackRef');\nconst seqRef = ref<HTMLUListElement | null>(null);\n\nconst seqWidth = ref<number>(0);\nconst copyCount = ref<number>(ANIMATION_CONFIG.MIN_COPIES);\nconst isHovered = ref<boolean>(false);\n\nlet rafRef: number | null = null;\nlet lastTimestampRef: number | null = null;\nconst offsetRef = ref(0);\nconst velocityRef = ref(0);\n\nconst targetVelocity = computed(() => {\n const magnitude = Math.abs(props.speed);\n const directionMultiplier = props.direction === 'left' ? 1 : -1;\n const speedMultiplier = props.speed < 0 ? -1 : 1;\n return magnitude * directionMultiplier * speedMultiplier;\n});\n\nconst cssVariables = computed(() => ({\n '--logoloop-gap': `${props.gap}px`,\n '--logoloop-logoHeight': `${props.logoHeight}px`,\n ...(props.fadeOutColor && { '--logoloop-fadeColor': props.fadeOutColor })\n}));\n\nconst rootClasses = computed(() => {\n const classes = [\n 'relative overflow-x-hidden group',\n '[--logoloop-gap:32px]',\n '[--logoloop-logoHeight:28px]',\n '[--logoloop-fadeColorAuto:#ffffff]',\n 'dark:[--logoloop-fadeColorAuto:#0b0b0b]'\n ];\n\n if (props.scaleOnHover) {\n classes.push('py-[calc(var(--logoloop-logoHeight)*0.1)]');\n }\n\n if (props.className) {\n classes.push(props.className);\n }\n\n return classes;\n});\n\nconst containerStyle = computed(() => ({\n width: typeof props.width === 'number' ? `${props.width}px` : props.width,\n ...cssVariables.value,\n ...(typeof props.style === 'object' && props.style !== null ? props.style : {})\n}));\n\nconst isNodeItem = (item: LogoItem): item is LogoItemNode => 'node' in item;\n\nconst getItemAriaLabel = (item: LogoItem): string | undefined => {\n if (isNodeItem(item)) {\n return item.ariaLabel ?? item.title;\n }\n return item.alt ?? item.title;\n};\n\nconst handleMouseEnter = () => {\n if (props.pauseOnHover) {\n isHovered.value = true;\n }\n};\n\nconst handleMouseLeave = () => {\n if (props.pauseOnHover) {\n isHovered.value = false;\n }\n};\n\nconst updateDimensions = async () => {\n await nextTick();\n const containerWidth = containerRef.value?.clientWidth ?? 0;\n const sequenceWidth = seqRef.value?.getBoundingClientRect?.()?.width ?? 0;\n\n if (sequenceWidth > 0) {\n seqWidth.value = Math.ceil(sequenceWidth);\n const copiesNeeded = Math.ceil(containerWidth / sequenceWidth) + ANIMATION_CONFIG.COPY_HEADROOM;\n copyCount.value = Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded);\n\n cleanupAnimation?.();\n cleanupAnimation = startAnimationLoop();\n }\n};\n\nlet resizeObserver: ResizeObserver | null = null;\nconst setupResizeObserver = () => {\n if (!window.ResizeObserver) {\n const handleResize = () => updateDimensions();\n window.addEventListener('resize', handleResize);\n updateDimensions();\n return () => window.removeEventListener('resize', handleResize);\n }\n\n resizeObserver = new ResizeObserver(updateDimensions);\n\n if (containerRef.value) {\n resizeObserver.observe(containerRef.value);\n }\n if (seqRef.value) {\n resizeObserver.observe(seqRef.value);\n }\n\n updateDimensions();\n\n return () => {\n resizeObserver?.disconnect();\n resizeObserver = null;\n };\n};\n\nconst setupImageLoader = () => {\n const images = seqRef.value?.querySelectorAll('img') ?? [];\n\n if (images.length === 0) {\n updateDimensions();\n return;\n }\n\n let remainingImages = images.length;\n const handleImageLoad = () => {\n remainingImages -= 1;\n if (remainingImages === 0) {\n updateDimensions();\n }\n };\n\n images.forEach(img => {\n const htmlImg = img as HTMLImageElement;\n if (htmlImg.complete) {\n handleImageLoad();\n } else {\n htmlImg.addEventListener('load', handleImageLoad, { once: true });\n htmlImg.addEventListener('error', handleImageLoad, { once: true });\n }\n });\n\n return () => {\n images.forEach(img => {\n img.removeEventListener('load', handleImageLoad);\n img.removeEventListener('error', handleImageLoad);\n });\n };\n};\n\nconst startAnimationLoop = () => {\n const track = trackRef.value;\n if (!track) return;\n\n const prefersReduced =\n typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n if (seqWidth.value > 0) {\n offsetRef.value = ((offsetRef.value % seqWidth.value) + seqWidth.value) % seqWidth.value;\n track.style.transform = `translate3d(${-offsetRef.value}px, 0, 0)`;\n }\n\n if (prefersReduced) {\n track.style.transform = 'translate3d(0, 0, 0)';\n return () => {\n lastTimestampRef = null;\n };\n }\n\n const animate = (timestamp: number) => {\n if (lastTimestampRef === null) {\n lastTimestampRef = timestamp;\n }\n\n const deltaTime = Math.max(0, timestamp - lastTimestampRef) / 1000;\n lastTimestampRef = timestamp;\n\n const target = props.pauseOnHover && isHovered.value ? 0 : targetVelocity.value;\n\n const easingFactor = 1 - Math.exp(-deltaTime / ANIMATION_CONFIG.SMOOTH_TAU);\n velocityRef.value += (target - velocityRef.value) * easingFactor;\n\n if (seqWidth.value > 0) {\n let nextOffset = offsetRef.value + velocityRef.value * deltaTime;\n nextOffset = ((nextOffset % seqWidth.value) + seqWidth.value) % seqWidth.value;\n offsetRef.value = nextOffset;\n\n const translateX = -offsetRef.value;\n track.style.transform = `translate3d(${translateX}px, 0, 0)`;\n }\n\n rafRef = requestAnimationFrame(animate);\n };\n\n rafRef = requestAnimationFrame(animate);\n\n return () => {\n if (rafRef !== null) {\n cancelAnimationFrame(rafRef);\n rafRef = null;\n }\n lastTimestampRef = null;\n };\n};\n\nlet cleanupResize: (() => void) | undefined;\nlet cleanupImages: (() => void) | undefined;\nlet cleanupAnimation: (() => void) | undefined;\n\nconst cleanup = () => {\n cleanupResize?.();\n cleanupImages?.();\n cleanupAnimation?.();\n};\n\nonMounted(async () => {\n await nextTick();\n setTimeout(() => {\n cleanupResize = setupResizeObserver();\n cleanupImages = setupImageLoader();\n }, 10);\n});\n\nonUnmounted(() => {\n cleanup();\n});\n\nwatch(\n [() => props.logos, () => props.gap, () => props.logoHeight],\n async () => {\n await nextTick();\n cleanupImages?.();\n cleanupImages = setupImageLoader();\n },\n { deep: true }\n);\n</script>\n\n<script lang=\"ts\">\nimport { defineComponent, h } from 'vue';\n\nconst LogoContent = defineComponent({\n name: 'LogoContent',\n props: {\n item: {\n type: Object as () => LogoItem,\n required: true\n },\n scaleOnHover: {\n type: Boolean,\n default: false\n }\n },\n setup(props) {\n const isNodeItem = (item: LogoItem): item is LogoItemNode => 'node' in item;\n\n return () => {\n const baseClasses = ['inline-flex items-center', 'motion-reduce:transition-none'];\n\n if (props.scaleOnHover) {\n baseClasses.push(\n 'transition-transform duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] group-hover/item:scale-120'\n );\n }\n\n if (isNodeItem(props.item)) {\n return h('span', {\n class: [\n ...baseClasses,\n 'text-[length:var(--logoloop-logoHeight)] [&>i]:text-[length:var(--logoloop-logoHeight)] [&>i]:leading-[1]'\n ],\n innerHTML: props.item.node,\n 'aria-hidden': !!(props.item as LogoItemNode).href && !(props.item as LogoItemNode).ariaLabel\n });\n } else {\n const imgClasses = [\n 'h-[var(--logoloop-logoHeight)] w-auto block object-contain',\n '[-webkit-user-drag:none] pointer-events-none',\n '[image-rendering:-webkit-optimize-contrast]',\n 'motion-reduce:transition-none'\n ];\n\n if (props.scaleOnHover) {\n imgClasses.push(\n 'transition-transform duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] group-hover/item:scale-120'\n );\n }\n\n return h('img', {\n class: imgClasses,\n src: props.item.src,\n srcset: props.item.srcSet,\n sizes: props.item.sizes,\n width: props.item.width,\n height: props.item.height,\n alt: props.item.alt ?? '',\n title: props.item.title,\n loading: 'lazy',\n decoding: 'async',\n draggable: false\n });\n }\n };\n }\n});\n\nexport { LogoContent };\n</script>\n","path":"LogoLoop/LogoLoop.vue","_imports_":[],"registryDependencies":[],"dependencies":[],"devDependencies":[]}],"registryDependencies":[],"dependencies":[],"devDependencies":[],"categories":["Animations"]} |