# Product Overview 1

> A product page with an image carousel, a size grid and colour swatches

A shadcn/ui block from the [@shadcnstore](https://www.shadcnstore.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/shadcnstore/product-overview-1).

- Page, with a live preview: https://shadcn.exchange/r/shadcnstore/product-overview-1
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/shadcnstore/product-overview-1-light-454daaff6fb4ae6b.png
- Registry item: https://shadcnstore.com/r/product-overview-1.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://shadcnstore.com/r/product-overview-1.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `button`, `carousel`

## Files

### `storage/app/registry-sources/e-commerce/product-overview/product-overview-1/page.tsx`

```tsx
import { ProductOverview1 } from "@/components/blocks/e-commerce/product-overview/product-overview-1/components/product-overview-1"

export default function Page() {
  return (
    <div className="flex min-h-svh w-full items-center justify-center">
      <div className="w-full">
        <ProductOverview1 />
      </div>
    </div>
  )
}
```

### `storage/app/registry-sources/e-commerce/product-overview/product-overview-1/product-overview-1.tsx`

```tsx
'use client'

import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { ProductOverview1ReactionToggle } from './product-overview-1-reaction-toggle'
import { cn } from '@/lib/utils'
import { Heart, Star } from 'lucide-react'
import { Carousel, CarouselContent, CarouselItem, type CarouselApi } from '@/components/ui/carousel'
import { type Product, productDetails } from "@/components/data/product-overview-1-data"

export function ProductOverview1() {
  const [selectedImage, setSelectedImage] = useState(0)
  const [selectedSize, setSelectedSize] = useState<number | null>(productDetails.sizes[0])
  const [selectedColor, setSelectedColor] = useState(productDetails.colors[0])
  const [isWishlisted, setIsWishlisted] = useState(false)
  const [carouselApi, setCarouselApi] = useState<CarouselApi>()

  useEffect(() => {
    if (!carouselApi) return

    // Set carousel to the selected image
    carouselApi.scrollTo(selectedImage)

    // Update selected image when carousel changes
    const handleSelect = () => {
      const currentIndex = carouselApi.selectedScrollSnap()
      setSelectedImage(currentIndex)
    }

    carouselApi.on('select', handleSelect)
    return () => {
      carouselApi.off('select', handleSelect)
    }
  }, [carouselApi, selectedImage])

  return (
    <div>
      {/* Product Details */}
      <section className='@container mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
        <div className='grid grid-cols-1 gap-6 py-4 lg:grid-cols-2 lg:gap-8 lg:py-6 xl:grid-cols-3 xl:gap-12 xl:py-12'>
          {/* Product Info */}
          <div className='flex flex-col justify-between gap-6 lg:gap-8'>
            <div className='flex flex-col gap-2 lg:gap-4'>
              <span className='text-sm font-semibold tracking-wide uppercase'>{productDetails.brand} —</span>
              <h2 className='text-xl font-bold tracking-tight text-balance lg:text-3xl'>{productDetails.name}</h2>
              <p className='text-muted-foreground text-balance'>{productDetails.description}</p>
              <p className='text-2xl font-bold tracking-tight'>
                {productDetails.currency}
                {productDetails.price}
              </p>
            </div>

            {/* Thumbnails */}
            <div className='flex flex-wrap gap-4'>
              {productDetails.images.map((image, index) => (
                <div
                  key={image.id}
                  onMouseEnter={() => setSelectedImage(index)}
                  className={cn(
                    'ring-offset-background size-16 cursor-pointer overflow-hidden rounded-sm ring-offset-2 transition-all lg:size-18',
                    selectedImage === index && 'ring-foreground ring-2',
                  )}
                >
                  <img src={image.src} alt={image.alt} className='size-full object-cover' />
                </div>
              ))}
            </div>
          </div>

          {/* Main Image */}
          <div className='row-span-2 row-start-1 lg:col-start-2'>
            <Carousel setApi={setCarouselApi} className='w-full'>
              <CarouselContent>
                {productDetails.images.map(image => (
                  <CarouselItem key={image.id}>
                    <img src={image.src} alt={image.alt} className='w-full h-90 rounded-lg object-cover' />
                  </CarouselItem>
                ))}
              </CarouselContent>
            </Carousel>
          </div>

          {/* Product Attributes */}
          <div className='flex flex-col gap-6 lg:gap-10'>
            {/* Size Selection */}
            <div className='flex flex-col gap-2'>
              <h3 className='font-bold'>Sizes</h3>
              <div className='flex flex-wrap gap-3'>
                {productDetails.sizes.map(size => (
                  <Button
                    key={size}
                    variant={selectedSize === size ? 'default' : 'outline'}
                    onClick={() => setSelectedSize(size)}
                    className="h-9 px-4 py-2 size-12 cursor-pointer rounded-full p-0"
                  >
                    {size}
                  </Button>
                ))}
              </div>
            </div>

            {/* Color Selection */}
            <div className='flex flex-col gap-2'>
              <h3 className='font-bold'>Color</h3>
              <div className='flex gap-3'>
                {productDetails.colors.map(color => (
                  <Button
                    key={color.name}
                    onClick={() => setSelectedColor(color)}
                    className={cn('h-9 px-4 py-2',
                      'ring-offset-background size-8 cursor-pointer rounded-full ring-offset-2 transition-all',
                      selectedColor.name === color.name && 'ring-foreground ring-2',
                      selectedColor.name !== color.name &&
                        ['Black', 'White'].includes(color.name) &&
                        'outline-muted outline-solid',
                    )}
                    style={{ backgroundColor: color.value }}
                    aria-label={`Select ${color.name} color`}
                  />
                ))}
              </div>
            </div>

            {/* Reviews */}
            <div className='flex flex-col gap-2'>
              <h3 className='font-bold'>Reviews</h3>
              <div className='flex gap-1'>
                {[...Array(5)].map((_, i) => (
                  <Star
                    key={i}
                    className='text-foreground size-5'
                    fill={i < productDetails.rating ? 'currentColor' : 'none'}
                  />
                ))}
              </div>
            </div>

            {/* Action Buttons */}
            <div className='flex gap-4'>
              <Button className="h-10 px-8 flex-1 cursor-pointer rounded-full" size='lg'>
                Add to Cart
              </Button>
              <Button
                variant='outline'
                size='icon'
                className="size-9 rounded-full cursor-pointer"
                onClick={() => setIsWishlisted(!isWishlisted)}
                aria-label={isWishlisted ? 'Remove from wishlist' : 'Add to wishlist'}
              >
                <ProductOverview1ReactionToggle active={isWishlisted} icon={Heart} iconClassName='size-5' activeColorClassName='text-primary' />
              </Button>
            </div>
          </div>
        </div>
      </section>
    </div>
  )
}

export default ProductOverview1
```

### `storage/app/registry-sources/e-commerce/product-overview/product-overview-1/product-overview-1-reaction-toggle.tsx`

```tsx
'use client'

import { useEffect, useRef, useState, type ComponentType } from 'react'

import { cn } from '@/lib/utils'

const PARTICLE_COUNT = 8

/**
 * The pop/burst keyframes travel with the component instead of living in the
 * app's stylesheet, so this file is self-contained: drop it in and the
 * animation works with no Tailwind config or global CSS to copy over.
 * Injected once per page, guarded by the marker attribute.
 */
const STYLE_MARKER = 'data-reaction-toggle-keyframes'
const STYLES = `
@keyframes reaction-toggle-pop {
  0% { transform: scale(1); }
  30% { transform: scale(0.82); }
  100% { transform: scale(1); }
}
@keyframes reaction-toggle-burst {
  0% { opacity: 0; transform: translate(0, 0) scale(0.4); }
  20% { opacity: 1; transform: translate(calc(var(--rx) * 0.25), calc(var(--ry) * 0.25)) scale(1); }
  100% { opacity: 0; transform: translate(var(--rx), var(--ry)) scale(0.6); }
}
.reaction-toggle-pop {
  animation: reaction-toggle-pop 350ms cubic-bezier(0.34, 1.96, 0.64, 1);
}
.reaction-toggle-burst {
  animation-name: reaction-toggle-burst;
  animation-timing-function: ease-out;
  animation-fill-mode: forwards;
}
@media (prefers-reduced-motion: reduce) {
  .reaction-toggle-pop,
  .reaction-toggle-burst { animation: none !important; }
}
`

function useReactionToggleStyles() {
  useEffect(() => {
    if (document.head.querySelector(`[${STYLE_MARKER}]`)) return

    const style = document.createElement('style')
    style.setAttribute(STYLE_MARKER, '')
    style.textContent = STYLES
    document.head.append(style)
  }, [])
}

type ReactionIconProps = {
  className?: string
}

type Particle = {
  rx: number
  ry: number
  duration: number
  delay: number
  size: number
}

function buildParticles(): Particle[] {
  return Array.from({ length: PARTICLE_COUNT }, (_, i) => {
    const angle = (Math.PI * 2 * i) / PARTICLE_COUNT + (Math.random() - 0.5) * 0.4
    const dist = 16 + Math.random() * 10

    return {
      rx: Math.round(Math.cos(angle) * dist * 10) / 10,
      ry: Math.round(Math.sin(angle) * dist * 10) / 10,
      duration: Math.round(500 + Math.random() * 200),
      delay: Math.round(Math.random() * 60),
      size: Math.round((0.7 + Math.random() * 0.8) * 100) / 100,
    }
  })
}

type ReactionToggleProps = {
  /** Whether the reaction is currently active (liked/helpful/etc). */
  active: boolean
  /** Icon component to render, e.g. `Heart` or `ThumbsUp` from lucide-react. */
  icon: ComponentType<ReactionIconProps>
  /** Wrapper class - sizing/positioning of the whole control. */
  className?: string
  /** Class applied to the icon itself (e.g. `size-5`). */
  iconClassName?: string
  /**
   * Tailwind color class (e.g. `text-primary`, `text-destructive`) applied to the
   * icon and particles while active. Drives `fill-current`/`bg-current`, so it
   * stays in sync with the design system instead of a hardcoded brand color.
   */
  activeColorClassName?: string
}

/**
 * Heart/thumbs-up style reaction control: the icon pops with an overshoot scale
 * and sprinkles 8 particles outward the moment it becomes active.
 */
export function ProductOverview1ReactionToggle({
  active,
  icon: Icon,
  className,
  iconClassName,
  activeColorClassName = 'text-primary',
}: ReactionToggleProps) {
  const [isBursting, setIsBursting] = useState(false)
  const wasActive = useRef(active)
  const particlesRef = useRef<Particle[]>(buildParticles())

  useReactionToggleStyles()

  useEffect(() => {
    if (active && !wasActive.current) {
      particlesRef.current = buildParticles()
      setIsBursting(true)
      const timeout = setTimeout(() => setIsBursting(false), 700)
      wasActive.current = active
      return () => clearTimeout(timeout)
    }

    wasActive.current = active
  }, [active])

  return (
    <span className={cn('relative inline-flex', className)}>
      <span className={cn('inline-flex', active && 'reaction-toggle-pop')}>
        <Icon
          className={cn(
            'transition-[fill,stroke,color] duration-150 ease-[cubic-bezier(0.22,1,0.36,1)]',
            active ? cn('fill-current', activeColorClassName) : 'fill-transparent',
            iconClassName
          )}
        />
      </span>

      {isBursting && (
        <span
          aria-hidden='true'
          className={cn('pointer-events-none absolute top-1/2 left-1/2 size-0', activeColorClassName)}
        >
          {particlesRef.current.map((particle, index) => (
            <i
              key={index}
              className='reaction-toggle-burst absolute rounded-full bg-current opacity-0'
              style={
                {
                  '--rx': `${particle.rx}px`,
                  '--ry': `${particle.ry}px`,
                  animationDuration: `${particle.duration}ms`,
                  animationDelay: `${particle.delay}ms`,
                  width: `${2.5 * particle.size}px`,
                  height: `${2.5 * particle.size}px`,
                  left: `${-1.25 * particle.size}px`,
                  top: `${-1.25 * particle.size}px`,
                } as React.CSSProperties
              }
            />
          ))}
        </span>
      )}
    </span>
  )
}

export default ProductOverview1ReactionToggle
```

### `storage/app/registry-sources/e-commerce/product-overview/product-overview-1/product-overview-1-data.ts`

```ts
export interface Product {
  id: string
  brand: string
  name: string
  description: string
  price: number
  currency: string
  rating: number
  images: {
    id: number
    src: string
    alt: string
  }[]
  sizes: number[]
  colors: {
    name: string
    value: string
  }[]
}

export const productDetails: Product = {
  id: 'high-tops-black',
  brand: 'Adidas',
  name: 'Black technical knit fabric high-tops',
  description: 'Running sneakers with thin elastic laces.',
  price: 450,
  currency: '$',
  rating: 4,
  images: [
    {
      id: 1,
      src: 'https://assets.shadcnstore.com/shadcnstore.com/stock/e-commerce/nike-zig-kinetica-3-front-view-2.800w.e3c867.avif',
      alt: 'Black technical knit fabric high-tops - View 1',
    },
    {
      id: 2,
      src: 'https://assets.shadcnstore.com/shadcnstore.com/stock/e-commerce/nike-zig-kinetica-3-side-view.800w.e023db.avif',
      alt: 'Black technical knit fabric high-tops - View 2',
    },
    {
      id: 3,
      src: 'https://assets.shadcnstore.com/shadcnstore.com/stock/e-commerce/nike-zig-kinetica-3-front-view.800w.75705b.avif',
      alt: 'Black technical knit fabric high-tops - View 3',
    },
    {
      id: 4,
      src: 'https://assets.shadcnstore.com/shadcnstore.com/stock/e-commerce/nike-zig-kinetica-3-back-view.800w.4d3306.avif',
      alt: 'Black technical knit fabric high-tops - View 4',
    },
  ],
  sizes: [37, 38, 39, 40, 41],
  colors: [
    { name: 'White', value: '#FFFFFF' },
    { name: 'Black', value: '#000000' },
    { name: 'Gray', value: '#6B7280' },
  ],
}
```
