Skip to content

← all comparisons

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.

React 1.2 KB gzip · 86 lines
ProductDetailReact.tsx
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>
  );
}
Vue 1.1 KB gzip · 84 lines
ProductDetailVue.vue
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>
Svelte 1.1 KB gzip · 78 lines
ProductDetailSvelte.svelte
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>