shadimage
svelte-bits
componentanimated-listsource: ok · hit

Animated List

Scroll-aware list with item-by-item scale + fade transitions and keyboard navigation.

npx shadcn add "https://sveltebits.xyz/r/animated-list.json"
free

Where it goes

kanban

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Files1 · source cached 10/8/2026, 2:54:46 PM

AnimatedList.svelte
<script lang="ts">
	import { onMount, untrack } from 'svelte';

	type Props = {
		items?: string[];
		onItemSelect?: (item: string, index: number) => void;
		showGradients?: boolean;
		enableArrowNavigation?: boolean;
		class?: string;
		itemClass?: string;
		displayScrollbar?: boolean;
		initialSelectedIndex?: number;
	};

	let {
		items = [
			'Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5',
			'Item 6', 'Item 7', 'Item 8', 'Item 9', 'Item 10',
			'Item 11', 'Item 12', 'Item 13', 'Item 14', 'Item 15'
		],
		onItemSelect,
		showGradients = true,
		enableArrowNavigation = true,
		class: className = '',
		itemClass = '',
		displayScrollbar = true,
		initialSelectedIndex = -1
	}: Props = $props();

	let listRef: HTMLDivElement;
	let selectedIndex = $state(initialSelectedIndex);
	let keyboardNav = $state(false);
	let topGradientOpacity = $state(0);
	let bottomGradientOpacity = $state(1);
	let inView = $state<boolean[]>([]);

	$effect(() => {
		if (inView.length !== items.length) {
			untrack(() => (inView = items.map(() => false)));
		}
	});

	function handleScroll(e: Event) {
		const t = e.currentTarget as HTMLDivElement;
		topGradientOpacity = Math.min(t.scrollTop / 50, 1);
		const bottomDistance = t.scrollHeight - (t.scrollTop + t.clientHeight);
		bottomGradientOpacity = t.scrollHeight <= t.clientHeight ? 0 : Math.min(bottomDistance / 50, 1);
	}

	function inViewAction(node: HTMLElement, index: number) {
		const io = new IntersectionObserver(
			(entries) => {
				for (const entry of entries) {
					inView[index] = entry.intersectionRatio >= 0.5;
				}
			},
			{ root: listRef, threshold: [0, 0.5, 1] }
		);
		io.observe(node);
		return { destroy: () => io.disconnect() };
	}

	onMount(() => {
		if (!enableArrowNavigation) return;
		const handler = (e: KeyboardEvent) => {
			if (e.key === 'ArrowDown' || (e.key === 'Tab' && !e.shiftKey)) {
				e.preventDefault();
				keyboardNav = true;
				selectedIndex = Math.min(selectedIndex + 1, items.length - 1);
			} else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
				e.preventDefault();
				keyboardNav = true;
				selectedIndex = Math.max(selectedIndex - 1, 0);
			} else if (e.key === 'Enter') {
				if (selectedIndex >= 0 && selectedIndex < items.length) {
					e.preventDefault();
					onItemSelect?.(items[selectedIndex], selectedIndex);
				}
			}
		};
		window.addEventListener('keydown', handler);
		return () => window.removeEventListener('keydown', handler);
	});

	$effect(() => {
		if (!keyboardNav || selectedIndex < 0 || !listRef) return;
		const container = listRef;
		const selectedItem = container.querySelector(
			`[data-index="${selectedIndex}"]`
		) as HTMLElement | null;
		if (selectedItem) {
			const extraMargin = 50;
			const itemTop = selectedItem.offsetTop;
			const itemBottom = itemTop + selectedItem.offsetHeight;
			if (itemTop < container.scrollTop + extraMargin) {
				container.scrollTo({ top: itemTop - extraMargin, behavior: 'smooth' });
			} else if (itemBottom > container.scrollTop + container.clientHeight - extraMargin) {
				container.scrollTo({
					top: itemBottom - container.clientHeight + extraMargin,
					behavior: 'smooth'
				});
			}
		}
		untrack(() => (keyboardNav = false));
	});
</script>

<div class="relative w-[500px] {className}">
	<div
		bind:this={listRef}
		class="al-scroll max-h-[400px] overflow-y-auto p-4 {displayScrollbar ? 'al-scrollbar' : 'al-scrollbar-hide'}"
		onscroll={handleScroll}
	>
		{#each items as item, index (index)}
			<div
				use:inViewAction={index}
				data-index={index}
				class="mb-4 cursor-pointer al-item"
				style:transform={inView[index] ? 'scale(1)' : 'scale(0.7)'}
				style:opacity={inView[index] ? 1 : 0}
				style:transition="transform 0.2s ease 0.1s, opacity 0.2s ease 0.1s"
				onmouseenter={() => (selectedIndex = index)}
				onclick={() => {
					selectedIndex = index;
					onItemSelect?.(item, index);
				}}
				role="option"
				aria-selected={selectedIndex === index}
				tabindex="-1"
			>
				<div
					class="p-4 bg-[#222] rounded-lg {selectedIndex === index ? 'al-selected' : ''} {itemClass}"
				>
					<p class="text-white m-0">{item}</p>
				</div>
			</div>
		{/each}
	</div>
	{#if showGradients}
		<div
			class="absolute top-0 left-0 right-0 h-[50px] pointer-events-none"
			style="background: linear-gradient(to bottom, #14110E, transparent); opacity: {topGradientOpacity}; transition: opacity 0.3s ease;"
		></div>
		<div
			class="absolute bottom-0 left-0 right-0 h-[100px] pointer-events-none"
			style="background: linear-gradient(to top, #14110E, transparent); opacity: {bottomGradientOpacity}; transition: opacity 0.3s ease;"
		></div>
	{/if}
</div>

<style>
	.al-scrollbar {
		scrollbar-width: thin;
		scrollbar-color: #222 #14110e;
	}
	.al-scrollbar::-webkit-scrollbar {
		width: 8px;
	}
	.al-scrollbar::-webkit-scrollbar-track {
		background: #14110e;
	}
	.al-scrollbar::-webkit-scrollbar-thumb {
		background: #222;
		border-radius: 4px;
	}
	.al-scrollbar-hide {
		scrollbar-width: none;
	}
	.al-scrollbar-hide::-webkit-scrollbar {
		display: none;
	}
	.al-selected {
		background: #222 !important;
	}
</style>

More from svelte-bits

Browse all components in this library →