Local UI state + nested data rendering
Product detail
Image gallery with selected-index local state, plus a reviews list with star ratings. The reviews loop is a small showcase of nested data rendering, and the star widget is reused both for the product rating and per-review rating.
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
useState(0) for the selected index. Stars is a small inner component that accepts rating and renders five filled-or-not glyphs. The reviews loop is a plain map with key={i}.
import { useState } from 'react';
import type { Product } from '../data/products';
import AddToCartButtonReact from './AddToCartButtonReact';
type Props = { product: Product };
function Stars({ rating }: { rating: number }) {
const filled = Math.round(rating);
return (
<span aria-label={`${rating.toFixed(1)} out of 5`} className="tracking-tight">
{Array.from({ length: 5 }).map((_, i) => (
<span key={i} className={i < filled ? 'text-yellow-500' : 'text-slate-300 dark:text-slate-600'}>
★
</span>
))}
</span>
);
}
export default function ProductDetailReact({ product }: Props) {
const [selected, setSelected] = useState(0);
return (
<div className="flex flex-col gap-8">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<img
src={product.images[selected]}
alt={product.title}
className="aspect-square w-full rounded-lg border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 object-cover"
/>
{product.images.length > 1 && (
<div className="mt-2 flex gap-2">
{product.images.map((src, i) => (
<button
key={src}
type="button"
onClick={() => setSelected(i)}
aria-label={`View image ${i + 1}`}
className={`h-14 w-14 overflow-hidden rounded border-2 ${
i === selected ? 'border-blue-600' : 'border-transparent'
}`}
>
<img src={src} alt="" className="h-full w-full object-cover" />
</button>
))}
</div>
)}
</div>
<div className="flex flex-col gap-3">
<span className="text-xs uppercase tracking-wider text-slate-500 dark:text-slate-400">{product.category}</span>
<h2 className="text-2xl font-bold">{product.title}</h2>
<div className="flex items-center gap-2 text-sm">
<Stars rating={product.rating} />
<span className="text-slate-500 dark:text-slate-400">{product.rating.toFixed(2)} ({product.reviews.length} reviews)</span>
</div>
<span className="text-xl tabular-nums text-blue-700 dark:text-blue-400">${product.price}</span>
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">{product.description}</p>
<div className="mt-2">
<AddToCartButtonReact product={product} />
</div>
</div>
</div>
{product.reviews.length > 0 && (
<section>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Reviews</h3>
<ul className="flex flex-col gap-3">
{product.reviews.map((r, i) => (
<li key={i} className="rounded border border-slate-200 dark:border-slate-800 p-3">
<div className="mb-1 flex items-center justify-between gap-2 text-sm">
<span className="font-medium">{r.reviewerName}</span>
<Stars rating={r.rating} />
</div>
<p className="text-sm text-slate-600 dark:text-slate-300">{r.comment}</p>
<p className="mt-1 text-xs text-slate-400">{new Date(r.date).toLocaleDateString()}</p>
</li>
))}
</ul>
</section>
)}
</div>
);
} How this works
ref(0) for the selected index. Stars are inlined as a v-for over [1..5] — no separate component because the template syntax already reads cleanly. Reviews loop with v-for and Math.round inline.
<script setup lang="ts">
import { ref } from 'vue';
import type { Product } from '../data/products';
import AddToCartButtonVue from './AddToCartButtonVue.vue';
const props = defineProps<{ product: Product }>();
const selected = ref(0);
</script>
<template>
<div class="flex flex-col gap-8">
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<img
:src="props.product.images[selected]"
:alt="props.product.title"
class="aspect-square w-full rounded-lg border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 object-cover"
/>
<div v-if="props.product.images.length > 1" class="mt-2 flex gap-2">
<button
v-for="(src, i) in props.product.images"
:key="src"
type="button"
@click="selected = i"
:aria-label="`View image ${i + 1}`"
:class="[
'h-14 w-14 overflow-hidden rounded border-2',
i === selected ? 'border-blue-600' : 'border-transparent',
]"
>
<img :src="src" alt="" class="h-full w-full object-cover" />
</button>
</div>
</div>
<div class="flex flex-col gap-3">
<span class="text-xs uppercase tracking-wider text-slate-500 dark:text-slate-400">{{ props.product.category }}</span>
<h2 class="text-2xl font-bold">{{ props.product.title }}</h2>
<div class="flex items-center gap-2 text-sm">
<span :aria-label="`${props.product.rating.toFixed(1)} out of 5`" class="tracking-tight">
<span
v-for="i in 5"
:key="i"
:class="i <= Math.round(props.product.rating) ? 'text-yellow-500' : 'text-slate-300 dark:text-slate-600'"
>★</span>
</span>
<span class="text-slate-500 dark:text-slate-400">
{{ props.product.rating.toFixed(2) }} ({{ props.product.reviews.length }} reviews)
</span>
</div>
<span class="text-xl tabular-nums text-blue-700 dark:text-blue-400">${{ props.product.price }}</span>
<p class="text-sm leading-relaxed text-slate-600 dark:text-slate-300">{{ props.product.description }}</p>
<div class="mt-2">
<AddToCartButtonVue :product="props.product" />
</div>
</div>
</div>
<section v-if="props.product.reviews.length > 0">
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Reviews</h3>
<ul class="flex flex-col gap-3">
<li
v-for="(r, i) in props.product.reviews"
:key="i"
class="rounded border border-slate-200 dark:border-slate-800 p-3"
>
<div class="mb-1 flex items-center justify-between gap-2 text-sm">
<span class="font-medium">{{ r.reviewerName }}</span>
<span :aria-label="`${r.rating} out of 5`" class="tracking-tight">
<span
v-for="n in 5"
:key="n"
:class="n <= Math.round(r.rating) ? 'text-yellow-500' : 'text-slate-300 dark:text-slate-600'"
>★</span>
</span>
</div>
<p class="text-sm text-slate-600 dark:text-slate-300">{{ r.comment }}</p>
<p class="mt-1 text-xs text-slate-400">{{ new Date(r.date).toLocaleDateString() }}</p>
</li>
</ul>
</section>
</div>
</template> How this works
$state(0) for the index. The Stars widget is a {#snippet stars(rating)} ... {/snippet} declared inline and called with {@render stars(product.rating)} — Svelte 5's parameterized snippets, like a tiny private component without the file boundary.
<script lang="ts">
import type { Product } from '../data/products';
import AddToCartButtonSvelte from './AddToCartButtonSvelte.svelte';
let { product }: { product: Product } = $props();
let selected = $state(0);
</script>
{#snippet stars(rating: number)}
<span aria-label={`${rating.toFixed(1)} out of 5`} class="tracking-tight">
{#each Array(5) as _, i (i)}
<span class={i < Math.round(rating) ? 'text-yellow-500' : 'text-slate-300 dark:text-slate-600'}>★</span>
{/each}
</span>
{/snippet}
<div class="flex flex-col gap-8">
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<img
src={product.images[selected]}
alt={product.title}
class="aspect-square w-full rounded-lg border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 object-cover"
/>
{#if product.images.length > 1}
<div class="mt-2 flex gap-2">
{#each product.images as src, i (src)}
<button
type="button"
onclick={() => (selected = i)}
aria-label={`View image ${i + 1}`}
class={`h-14 w-14 overflow-hidden rounded border-2 ${
i === selected ? 'border-blue-600' : 'border-transparent'
}`}
>
<img {src} alt="" class="h-full w-full object-cover" />
</button>
{/each}
</div>
{/if}
</div>
<div class="flex flex-col gap-3">
<span class="text-xs uppercase tracking-wider text-slate-500 dark:text-slate-400">{product.category}</span>
<h2 class="text-2xl font-bold">{product.title}</h2>
<div class="flex items-center gap-2 text-sm">
{@render stars(product.rating)}
<span class="text-slate-500 dark:text-slate-400">
{product.rating.toFixed(2)} ({product.reviews.length} reviews)
</span>
</div>
<span class="text-xl tabular-nums text-blue-700 dark:text-blue-400">${product.price}</span>
<p class="text-sm leading-relaxed text-slate-600 dark:text-slate-300">{product.description}</p>
<div class="mt-2">
<AddToCartButtonSvelte {product} />
</div>
</div>
</div>
{#if product.reviews.length > 0}
<section>
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Reviews</h3>
<ul class="flex flex-col gap-3">
{#each product.reviews as r, i (i)}
<li class="rounded border border-slate-200 dark:border-slate-800 p-3">
<div class="mb-1 flex items-center justify-between gap-2 text-sm">
<span class="font-medium">{r.reviewerName}</span>
{@render stars(r.rating)}
</div>
<p class="text-sm text-slate-600 dark:text-slate-300">{r.comment}</p>
<p class="mt-1 text-xs text-slate-400">{new Date(r.date).toLocaleDateString()}</p>
</li>
{/each}
</ul>
</section>
{/if}
</div>