diff --git a/src/components/LogoLoop.css b/src/components/LogoLoop.css
new file mode 100644
index 0000000..ac05717
--- /dev/null
+++ b/src/components/LogoLoop.css
@@ -0,0 +1,147 @@
+.logoloop {
+ position: relative;
+ overflow: hidden;
+}
+
+.logoloop:not(.logoloop--vertical) {
+ overflow-x: hidden;
+}
+
+.logoloop--vertical {
+ overflow-y: hidden;
+ height: 100%;
+ display: inline-block;
+}
+
+.logoloop__track {
+ display: flex;
+ flex-direction: row;
+ width: max-content;
+ will-change: transform;
+ user-select: none;
+ position: relative;
+ z-index: 0;
+}
+
+.logoloop__track--vertical {
+ flex-direction: column;
+ width: 100%;
+ height: max-content;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .logoloop__track {
+ transform: none !important;
+ }
+}
+
+.logoloop__list {
+ display: flex;
+ align-items: center;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.logoloop__list--vertical {
+ flex-direction: column;
+}
+
+.logoloop__item {
+ flex: none;
+ font-size: var(--logoloop-logoHeight, 36px);
+ line-height: 1;
+}
+
+.logoloop__list:not(.logoloop__list--vertical) .logoloop__item {
+ margin-right: var(--logoloop-gap, 32px);
+}
+
+.logoloop__list--vertical .logoloop__item {
+ margin-bottom: var(--logoloop-gap, 32px);
+}
+
+.logoloop__item--scalable {
+ overflow: visible;
+}
+
+.logoloop__link {
+ display: inline-flex;
+ align-items: center;
+ text-decoration: none;
+ border-radius: 4px;
+ transition: opacity 0.2s linear;
+ color: inherit;
+}
+
+.logoloop__link:hover {
+ opacity: 0.8;
+}
+
+.logoloop__link:focus-visible {
+ outline: 2px solid currentColor;
+ outline-offset: 2px;
+}
+
+.logoloop__node {
+ display: inline-flex;
+ align-items: center;
+}
+
+.logoloop__node--scale,
+.logoloop__img--scale {
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.logoloop__item--scalable:hover .logoloop__node--scale,
+.logoloop__item--scalable:hover .logoloop__img--scale {
+ transform: scale(1.18);
+}
+
+.logoloop__img {
+ height: var(--logoloop-logoHeight, 36px);
+ width: auto;
+ display: block;
+ object-fit: contain;
+ -webkit-user-drag: none;
+ pointer-events: none;
+ image-rendering: -webkit-optimize-contrast;
+}
+
+.logoloop__fade {
+ position: absolute;
+ pointer-events: none;
+ z-index: 10;
+}
+
+.logoloop__fade--left {
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: clamp(24px, 8%, 120px);
+ background: linear-gradient(to right, var(--logoloop-fadeColor, var(--bg-primary, #0b0b0b)) 0%, transparent 100%);
+}
+
+.logoloop__fade--right {
+ top: 0;
+ bottom: 0;
+ right: 0;
+ width: clamp(24px, 8%, 120px);
+ background: linear-gradient(to left, var(--logoloop-fadeColor, var(--bg-primary, #0b0b0b)) 0%, transparent 100%);
+}
+
+.logoloop__fade--top {
+ left: 0;
+ right: 0;
+ top: 0;
+ height: clamp(24px, 8%, 120px);
+ background: linear-gradient(to bottom, var(--logoloop-fadeColor, var(--bg-primary, #0b0b0b)) 0%, transparent 100%);
+}
+
+.logoloop__fade--bottom {
+ left: 0;
+ right: 0;
+ bottom: 0;
+ height: clamp(24px, 8%, 120px);
+ background: linear-gradient(to top, var(--logoloop-fadeColor, var(--bg-primary, #0b0b0b)) 0%, transparent 100%);
+}
diff --git a/src/components/LogoLoop.jsx b/src/components/LogoLoop.jsx
new file mode 100644
index 0000000..e0a8482
--- /dev/null
+++ b/src/components/LogoLoop.jsx
@@ -0,0 +1,322 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import './LogoLoop.css';
+
+const ANIMATION_CONFIG = {
+ SMOOTH_TAU: 0.25,
+ MIN_COPIES: 2,
+ COPY_HEADROOM: 2,
+};
+
+const toCssLength = (value) =>
+ typeof value === 'number' ? `${value}px` : value ?? undefined;
+
+const cx = (...parts) => parts.filter(Boolean).join(' ');
+
+function useResizeObserver(callback, elements, deps) {
+ useEffect(() => {
+ if (!window.ResizeObserver) {
+ const handler = () => callback();
+ window.addEventListener('resize', handler);
+ callback();
+ return () => window.removeEventListener('resize', handler);
+ }
+ const observers = elements.map((ref) => {
+ if (!ref.current) return null;
+ const observer = new ResizeObserver(callback);
+ observer.observe(ref.current);
+ return observer;
+ });
+ callback();
+ return () => {
+ observers.forEach((o) => o?.disconnect());
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, deps);
+}
+
+function useImageLoader(seqRef, onLoad, deps) {
+ useEffect(() => {
+ const images = seqRef.current?.querySelectorAll('img') ?? [];
+ if (images.length === 0) {
+ onLoad();
+ return;
+ }
+ let remaining = images.length;
+ const handleLoad = () => {
+ remaining -= 1;
+ if (remaining === 0) onLoad();
+ };
+ images.forEach((img) => {
+ if (img.complete) {
+ handleLoad();
+ } else {
+ img.addEventListener('load', handleLoad, { once: true });
+ img.addEventListener('error', handleLoad, { once: true });
+ }
+ });
+ return () => {
+ images.forEach((img) => {
+ img.removeEventListener('load', handleLoad);
+ img.removeEventListener('error', handleLoad);
+ });
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, deps);
+}
+
+function useAnimationLoop(trackRef, targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical) {
+ const rafRef = useRef(null);
+ const lastTsRef = useRef(null);
+ const offsetRef = useRef(0);
+ const velocityRef = useRef(0);
+
+ useEffect(() => {
+ const track = trackRef.current;
+ if (!track) return;
+
+ const prefersReduced =
+ typeof window !== 'undefined' &&
+ window.matchMedia &&
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+ const seqSize = isVertical ? seqHeight : seqWidth;
+
+ if (seqSize > 0) {
+ offsetRef.current = ((offsetRef.current % seqSize) + seqSize) % seqSize;
+ track.style.transform = isVertical
+ ? `translate3d(0, ${-offsetRef.current}px, 0)`
+ : `translate3d(${-offsetRef.current}px, 0, 0)`;
+ }
+
+ if (prefersReduced) {
+ track.style.transform = 'translate3d(0, 0, 0)';
+ return () => {
+ lastTsRef.current = null;
+ };
+ }
+
+ const animate = (ts) => {
+ if (lastTsRef.current === null) lastTsRef.current = ts;
+ const dt = Math.max(0, ts - lastTsRef.current) / 1000;
+ lastTsRef.current = ts;
+
+ const target = isHovered && hoverSpeed !== undefined ? hoverSpeed : targetVelocity;
+ const easing = 1 - Math.exp(-dt / ANIMATION_CONFIG.SMOOTH_TAU);
+ velocityRef.current += (target - velocityRef.current) * easing;
+
+ if (seqSize > 0) {
+ let next = offsetRef.current + velocityRef.current * dt;
+ next = ((next % seqSize) + seqSize) % seqSize;
+ offsetRef.current = next;
+ track.style.transform = isVertical
+ ? `translate3d(0, ${-offsetRef.current}px, 0)`
+ : `translate3d(${-offsetRef.current}px, 0, 0)`;
+ }
+ rafRef.current = requestAnimationFrame(animate);
+ };
+
+ rafRef.current = requestAnimationFrame(animate);
+ return () => {
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
+ rafRef.current = null;
+ lastTsRef.current = null;
+ };
+ }, [trackRef, targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical]);
+}
+
+export default function LogoLoop({
+ logos,
+ speed = 60,
+ direction = 'left',
+ width = '100%',
+ logoHeight = 36,
+ gap = 32,
+ pauseOnHover = true,
+ hoverSpeed,
+ fadeOut = true,
+ fadeOutColor,
+ scaleOnHover = true,
+ ariaLabel = 'Skill logos',
+ className,
+ style,
+}) {
+ const containerRef = useRef(null);
+ const trackRef = useRef(null);
+ const seqRef = useRef(null);
+
+ const [seqWidth, setSeqWidth] = useState(0);
+ const [seqHeight, setSeqHeight] = useState(0);
+ const [copyCount, setCopyCount] = useState(ANIMATION_CONFIG.MIN_COPIES);
+ const [isHovered, setIsHovered] = useState(false);
+
+ const effectiveHoverSpeed = useMemo(() => {
+ if (hoverSpeed !== undefined) return hoverSpeed;
+ if (pauseOnHover === true) return 0;
+ if (pauseOnHover === false) return undefined;
+ return 0;
+ }, [hoverSpeed, pauseOnHover]);
+
+ const isVertical = direction === 'up' || direction === 'down';
+
+ const targetVelocity = useMemo(() => {
+ const magnitude = Math.abs(speed);
+ const dirMul = isVertical
+ ? direction === 'up'
+ ? 1
+ : -1
+ : direction === 'left'
+ ? 1
+ : -1;
+ const speedMul = speed < 0 ? -1 : 1;
+ return magnitude * dirMul * speedMul;
+ }, [speed, direction, isVertical]);
+
+ const updateDimensions = useCallback(() => {
+ const containerWidth = containerRef.current?.clientWidth ?? 0;
+ const seqRect = seqRef.current?.getBoundingClientRect?.();
+ const sw = seqRect?.width ?? 0;
+ const sh = seqRect?.height ?? 0;
+ if (isVertical) {
+ const parentH = containerRef.current?.parentElement?.clientHeight ?? 0;
+ if (containerRef.current && parentH > 0) {
+ const h = Math.ceil(parentH);
+ if (containerRef.current.style.height !== `${h}px`)
+ containerRef.current.style.height = `${h}px`;
+ }
+ if (sh > 0) {
+ setSeqHeight(Math.ceil(sh));
+ const viewport = containerRef.current?.clientHeight ?? parentH ?? sh;
+ const copies = Math.ceil(viewport / sh) + ANIMATION_CONFIG.COPY_HEADROOM;
+ setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copies));
+ }
+ } else if (sw > 0) {
+ setSeqWidth(Math.ceil(sw));
+ const copies = Math.ceil(containerWidth / sw) + ANIMATION_CONFIG.COPY_HEADROOM;
+ setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copies));
+ }
+ }, [isVertical]);
+
+ useResizeObserver(updateDimensions, [containerRef, seqRef], [logos, gap, logoHeight, isVertical]);
+ useImageLoader(seqRef, updateDimensions, [logos, gap, logoHeight, isVertical]);
+ useAnimationLoop(trackRef, targetVelocity, seqWidth, seqHeight, isHovered, effectiveHoverSpeed, isVertical);
+
+ const cssVars = useMemo(() => ({
+ '--logoloop-gap': `${gap}px`,
+ '--logoloop-logoHeight': `${logoHeight}px`,
+ ...(fadeOutColor && { '--logoloop-fadeColor': fadeOutColor }),
+ }), [gap, logoHeight, fadeOutColor]);
+
+ const containerStyle = useMemo(() => ({
+ width: isVertical
+ ? toCssLength(width) === '100%'
+ ? undefined
+ : toCssLength(width)
+ : toCssLength(width) ?? '100%',
+ ...cssVars,
+ ...style,
+ }), [width, cssVars, style, isVertical]);
+
+ const handleEnter = useCallback(() => {
+ if (effectiveHoverSpeed !== undefined) setIsHovered(true);
+ }, [effectiveHoverSpeed]);
+ const handleLeave = useCallback(() => {
+ if (effectiveHoverSpeed !== undefined) setIsHovered(false);
+ }, [effectiveHoverSpeed]);
+
+ const renderItem = (item, key) => {
+ const isNode = 'node' in item;
+ const inner = isNode ? (
+
+ {item.node}
+
+ ) : (
+
+ );
+ const itemAriaLabel = isNode ? item.ariaLabel ?? item.title : item.alt ?? item.title;
+ const wrapper = item.href ? (
+
+ {inner}
+
+ ) : (
+ inner
+ );
+ return (
+