DOM refs
Product carousel
Capture a DOM element from the template so the prev/next buttons can call scrollBy() on it.
Each panel header shows the source size of that component file (raw, gzipped, line count). Source-only — framework runtime (React ~45 KB, Vue ~35 KB, Svelte ~5 KB gzipped) is shared across every island and isn't included in these numbers.
How this works
useRef returns a stable object across renders. Pass it via the JSX ref prop and read element through .current. Always nullable — the element doesn't exist before the first commit. The fetch lives in useEffect with a cancelled flag.
import { useEffect, useRef, useState } from 'react';
import { fetchProducts, type Product } from '../data/products';
const SCROLL_STEP = 240;
export default function ProductCarouselReact() {
const [products, setProducts] = useState<Product[]>([]);
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
const scrollerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let cancelled = false;
fetchProducts()
.then((data) => {
if (!cancelled) {
setProducts(data);
setStatus('ready');
}
})
.catch(() => {
if (!cancelled) setStatus('error');
});
return () => {
cancelled = true;
};
}, []);
const scroll = (dir: -1 | 1) => {
scrollerRef.current?.scrollBy({ left: dir * SCROLL_STEP, behavior: 'smooth' });
};
if (status === 'loading') {
return (
<div className="flex gap-3 overflow-hidden pb-2">
{Array.from({ length: 6 }).map((_, i) => (
<article key={i} className="flex w-52 shrink-0 animate-pulse flex-col overflow-hidden rounded-md border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
<div className="aspect-square w-full bg-slate-200 dark:bg-slate-700" />
<div className="flex flex-col gap-2 p-3">
<div className="h-4 w-3/4 rounded bg-slate-200 dark:bg-slate-700" />
<div className="h-3 w-1/4 rounded bg-slate-200 dark:bg-slate-700" />
</div>
</article>
))}
</div>
);
}
if (status === 'error') return <p className="text-red-600">Failed to load products.</p>;
return (
<div className="relative">
<button
type="button"
onClick={() => scroll(-1)}
aria-label="Scroll left"
className="absolute left-0 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-full border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-2 shadow hover:bg-slate-50 dark:hover:bg-slate-800"
>‹</button>
<div
ref={scrollerRef}
className="flex snap-x snap-mandatory gap-3 overflow-x-auto scroll-smooth pb-2"
>
{products.map((p) => (
<a
key={p.id}
href={`/products/${p.slug}`}
className="flex w-52 shrink-0 snap-start flex-col overflow-hidden rounded-md border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 hover:border-blue-300 dark:hover:border-blue-600"
>
<img src={p.thumbnail} alt={p.title} loading="lazy" className="aspect-square w-full object-cover" />
<div className="flex flex-col gap-1 p-3">
<span className="truncate text-sm font-semibold">{p.title}</span>
<span className="tabular-nums text-blue-700 dark:text-blue-400">${p.price}</span>
</div>
</a>
))}
</div>
<button
type="button"
onClick={() => scroll(1)}
aria-label="Scroll right"
className="absolute right-0 top-1/2 z-10 translate-x-1/2 -translate-y-1/2 rounded-full border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-2 shadow hover:bg-slate-50 dark:hover:bg-slate-800"
>›</button>
</div>
);
} How this works
Declare a normal ref(null), give the element a string name in the template (ref="scroller"), and Vue auto-binds it. Access through .value. The fetch goes in onMounted async.
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { fetchProducts, type Product } from '../data/products';
const SCROLL_STEP = 240;
const products = ref<Product[]>([]);
const status = ref<'loading' | 'ready' | 'error'>('loading');
const scroller = ref<HTMLDivElement | null>(null);
onMounted(async () => {
try {
products.value = await fetchProducts();
status.value = 'ready';
} catch {
status.value = 'error';
}
});
const scroll = (dir: -1 | 1) => {
scroller.value?.scrollBy({ left: dir * SCROLL_STEP, behavior: 'smooth' });
};
</script>
<template>
<div v-if="status === 'loading'" class="flex gap-3 overflow-hidden pb-2">
<article
v-for="i in 6"
:key="i"
class="flex w-52 shrink-0 animate-pulse flex-col overflow-hidden rounded-md border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900"
>
<div class="aspect-square w-full bg-slate-200 dark:bg-slate-700" />
<div class="flex flex-col gap-2 p-3">
<div class="h-4 w-3/4 rounded bg-slate-200 dark:bg-slate-700" />
<div class="h-3 w-1/4 rounded bg-slate-200 dark:bg-slate-700" />
</div>
</article>
</div>
<p v-else-if="status === 'error'" class="text-red-600">Failed to load products.</p>
<div v-else class="relative">
<button
type="button"
@click="scroll(-1)"
aria-label="Scroll left"
class="absolute left-0 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-full border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-2 shadow hover:bg-slate-50 dark:hover:bg-slate-800"
>‹</button>
<div
ref="scroller"
class="flex snap-x snap-mandatory gap-3 overflow-x-auto scroll-smooth pb-2"
>
<a
v-for="p in products"
:key="p.id"
:href="`/products/${p.slug}`"
class="flex w-52 shrink-0 snap-start flex-col overflow-hidden rounded-md border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 hover:border-blue-300 dark:hover:border-blue-600"
>
<img :src="p.thumbnail" :alt="p.title" loading="lazy" class="aspect-square w-full object-cover" />
<div class="flex flex-col gap-1 p-3">
<span class="truncate text-sm font-semibold">{{ p.title }}</span>
<span class="tabular-nums text-blue-700 dark:text-blue-400">${{ p.price }}</span>
</div>
</a>
</div>
<button
type="button"
@click="scroll(1)"
aria-label="Scroll right"
class="absolute right-0 top-1/2 z-10 translate-x-1/2 -translate-y-1/2 rounded-full border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-2 shadow hover:bg-slate-50 dark:hover:bg-slate-800"
>›</button>
</div>
</template> How this works
{#await} for the fetch — same simplification as in ProductList. bind:this={scroller} captures the element directly, no .current or .value. The $state wrap on the variable is a Svelte 5 quirk; in Svelte 4 a bare let was enough.
<script lang="ts">
import { fetchProducts } from '../data/products';
const SCROLL_STEP = 240;
const productsPromise = fetchProducts();
let scroller: HTMLDivElement | undefined = $state();
function scroll(dir: -1 | 1) {
scroller?.scrollBy({ left: dir * SCROLL_STEP, behavior: 'smooth' });
}
</script>
{#await productsPromise}
<div class="flex gap-3 overflow-hidden pb-2">
{#each Array(6) as _, i (i)}
<article class="flex w-52 shrink-0 animate-pulse flex-col overflow-hidden rounded-md border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
<div class="aspect-square w-full bg-slate-200 dark:bg-slate-700"></div>
<div class="flex flex-col gap-2 p-3">
<div class="h-4 w-3/4 rounded bg-slate-200 dark:bg-slate-700"></div>
<div class="h-3 w-1/4 rounded bg-slate-200 dark:bg-slate-700"></div>
</div>
</article>
{/each}
</div>
{:then products}
<div class="relative">
<button
type="button"
onclick={() => scroll(-1)}
aria-label="Scroll left"
class="absolute left-0 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-full border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-2 shadow hover:bg-slate-50 dark:hover:bg-slate-800"
>‹</button>
<div
bind:this={scroller}
class="flex snap-x snap-mandatory gap-3 overflow-x-auto scroll-smooth pb-2"
>
{#each products as p (p.id)}
<a
href={`/products/${p.slug}`}
class="flex w-52 shrink-0 snap-start flex-col overflow-hidden rounded-md border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 hover:border-blue-300 dark:hover:border-blue-600"
>
<img src={p.thumbnail} alt={p.title} loading="lazy" class="aspect-square w-full object-cover" />
<div class="flex flex-col gap-1 p-3">
<span class="truncate text-sm font-semibold">{p.title}</span>
<span class="tabular-nums text-blue-700 dark:text-blue-400">${p.price}</span>
</div>
</a>
{/each}
</div>
<button
type="button"
onclick={() => scroll(1)}
aria-label="Scroll right"
class="absolute right-0 top-1/2 z-10 translate-x-1/2 -translate-y-1/2 rounded-full border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-2 shadow hover:bg-slate-50 dark:hover:bg-slate-800"
>›</button>
</div>
{:catch}
<p class="text-red-600">Failed to load products.</p>
{/await}