# Listing 2

> A category listing that scrolls horizontally through destination cards

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

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

## Install

```bash
npx shadcn@latest add https://shadcnstore.com/r/listing-2.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`

## Files

### `storage/app/registry-sources/marketing/listings/listing-2/page.tsx`

```tsx
import { Listing2 } from "@/components/blocks/marketing/listings/listing-2/components/listing-2"

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

### `storage/app/registry-sources/marketing/listings/listing-2/listing-2.tsx`

```tsx
'use client'

import { Button } from '@/components/ui/button'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { categories } from "@/components/data/listing-2-data"

export function Listing2() {
  const containerRef = useRef<HTMLDivElement>(null)
  const [scrollPosition, setScrollPosition] = useState(0)
  const [maxScroll, setMaxScroll] = useState(0)

  useEffect(() => {
    const handleResize = () => {
      if (containerRef.current) {
        const { scrollWidth, clientWidth } = containerRef.current
        setMaxScroll(scrollWidth - clientWidth)
      }
    }

    handleResize()
    window.addEventListener('resize', handleResize)
    return () => window.removeEventListener('resize', handleResize)
  }, [])

  const handleScroll = (direction: 'left' | 'right') => {
    if (containerRef.current) {
      const scrollAmount = direction === 'left' ? -248 : 248
      const newPosition = Math.min(Math.max(0, scrollPosition + scrollAmount), maxScroll)
      containerRef.current.scrollTo({ left: newPosition, behavior: 'smooth' })
      setScrollPosition(newPosition)
    }
  }

  const onScroll = () => {
    if (containerRef.current) {
      setScrollPosition(containerRef.current.scrollLeft)
    }
  }

  return (
    <section className='relative py-16' aria-label='Categories'>
      <div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
        {/* Section Header */}
        <div className='mb-12 text-center'>
          <div className='relative mb-4 inline-block'>
            <h2 className='text-foreground text-3xl font-bold text-balance sm:text-4xl'>Categories</h2>
          </div>
          <p className='text-muted-foreground mx-auto mt-4 max-w-2xl text-base text-balance sm:text-lg'>
            Here are lots of interesting destinations to visit, but don't be confused - they're already grouped by
            category.
          </p>
        </div>

        {/* Categories Carousel */}
        <div className='relative w-full'>
          {/* Navigation Buttons */}
          <div className='absolute -start-8 top-1/2 z-10 -translate-y-1/2'>
            <Button
              variant='secondary'
              size='icon'
              className={`size-10 cursor-pointer rounded-full shadow-lg transition-opacity ${
                scrollPosition === 0 ? 'pointer-events-none opacity-40' : 'opacity-100'
              }`}
              onClick={() => handleScroll('left')}
              disabled={scrollPosition === 0}
              aria-label='Scroll left'
            >
              <ChevronLeft className='size-5' />
            </Button>
          </div>
          <div className='absolute -end-8 top-1/2 z-10 -translate-y-1/2'>
            <Button
              variant='secondary'
              size='icon'
              className={`size-10 cursor-pointer rounded-full shadow-lg transition-opacity ${
                scrollPosition >= maxScroll ? 'pointer-events-none opacity-40' : 'opacity-100'
              }`}
              onClick={() => handleScroll('right')}
              disabled={scrollPosition >= maxScroll}
              aria-label='Scroll right'
            >
              <ChevronRight className='size-5' />
            </Button>
          </div>

          {/* Scrollable Container */}
          <div className='overflow-hidden'>
            <div
              ref={containerRef}
              onScroll={onScroll}
              className='scrollbar-none flex w-full gap-6 overflow-x-auto scroll-smooth pt-6 pb-8 transition-opacity duration-500'
              style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
              role='list'
              aria-label='Categories list'
            >
              {categories.map(category => (
                <div
                  key={category.id}
                  className='w-[calc(50%-12px)] shrink-0 transition-transform duration-300 hover:scale-[1.02] sm:w-[calc(33.333%-16px)] md:w-[calc(25%-18px)] lg:w-[calc(20%-19.2px)]'
                  role='listitem'
                >
                  <article className='relative size-full'>
                    <div className='group relative h-0 w-full overflow-hidden rounded-full pb-[133.33%] shadow-md transition-all duration-300 hover:shadow-lg'>
                      <div className='absolute inset-0 size-full'>
                        <img
                          src={category.image}
                          alt={category.alt}
                          className='size-full object-cover transition-transform duration-300 group-hover:scale-105'
                          loading='lazy'
                          width={300}
                          height={400}
                        />
                        <div className='absolute inset-0 bg-linear-to-t from-black/70 via-black/10 to-transparent' />
                        <div className='absolute start-0 end-0 bottom-0 p-4 text-center'>
                          <h3 className='text-sm font-medium text-white sm:text-base md:text-lg'>{category.title}</h3>
                        </div>
                      </div>
                    </div>
                  </article>
                </div>
              ))}
            </div>
          </div>
        </div>
      </div>
    </section>
  )
}

export default Listing2
```

### `storage/app/registry-sources/marketing/listings/listing-2/listing-2-data.ts`

```ts
export interface Category {
  id: number
  title: string
  image: string
  alt: string
}

export const categories: Category[] = [
  {
    id: 1,
    title: 'Pyramid',
    image: 'https://assets.shadcnstore.com/shadcnstore.com/stock/marketing/pyramid-with-camel-in-egypt-2.500w.eb3094.avif',
    alt: 'Pyramid with camel in Egypt',
  },
  {
    id: 2,
    title: 'Mountain',
    image: 'https://assets.shadcnstore.com/shadcnstore.com/stock/marketing/pyramid-with-camel-in-egypt.500w.df9201.avif',
    alt: 'Beautiful mountain landscape',
  },
  {
    id: 3,
    title: 'The Mosque',
    image: 'https://assets.shadcnstore.com/shadcnstore.com/stock/marketing/beautiful-mountain-landscape.500w.b23f02.avif',
    alt: 'Sheikh Zayed Grand Mosque',
  },
  {
    id: 4,
    title: 'Desert',
    image: 'https://assets.shadcnstore.com/shadcnstore.com/stock/marketing/sheikh-zayed-grand-mosque.800w.fd1148.avif',
    alt: 'Desert sunset with sand dunes',
  },
  {
    id: 5,
    title: 'Tower',
    image: 'https://assets.shadcnstore.com/shadcnstore.com/stock/marketing/desert-sunset-with-sand-dunes.500w.92c7d0.avif',
    alt: 'Eiffel Tower in Paris',
  },
  {
    id: 6,
    title: 'Beach',
    image: 'https://assets.shadcnstore.com/shadcnstore.com/stock/marketing/eiffel-tower-in-paris.500w.d53fe2.avif',
    alt: 'Beautiful beach with seagulls',
  },
  {
    id: 7,
    title: 'Island',
    image: 'https://assets.shadcnstore.com/shadcnstore.com/stock/marketing/beautiful-beach-with-seagulls.500w.37a8ef.avif',
    alt: 'Tropical island paradise',
  },
  {
    id: 8,
    title: 'Waterfall',
    image: 'https://assets.shadcnstore.com/shadcnstore.com/stock/marketing/tropical-island-paradise.500w.7ee0ba.avif',
    alt: 'Majestic waterfall in nature',
  },
  {
    id: 9,
    title: 'Temple',
    image: 'https://assets.shadcnstore.com/shadcnstore.com/stock/marketing/majestic-waterfall-in-nature.500w.c68c17.avif',
    alt: 'Ancient temple in Asia',
  },
  {
    id: 10,
    title: 'Lake',
    image: 'https://assets.shadcnstore.com/shadcnstore.com/stock/marketing/professional-outdoor-adventure-gear-and-equipment.1200w.4ed779.avif',
    alt: 'Serene lake with mountain reflection',
  },
  {
    id: 11,
    title: 'City',
    image: 'https://assets.shadcnstore.com/shadcnstore.com/stock/marketing/serene-lake-with-mountain-reflection.500w.2b3385.avif',
    alt: 'Modern city skyline at night',
  },
  {
    id: 12,
    title: 'Castle',
    image: 'https://assets.shadcnstore.com/shadcnstore.com/stock/marketing/modern-city-skyline-at-night.500w.87704e.avif',
    alt: 'Medieval castle in Germany',
  },
  {
    id: 13,
    title: 'Forest',
    image: 'https://assets.shadcnstore.com/shadcnstore.com/stock/marketing/medieval-castle-in-germany.500w.bd5cac.avif',
    alt: 'Mystical forest path',
  },
  {
    id: 14,
    title: 'Glacier',
    image: 'https://assets.shadcnstore.com/shadcnstore.com/stock/marketing/mystical-forest-path.500w.1b2b8b.avif',
    alt: 'Majestic glacier landscape',
  },
  {
    id: 15,
    title: 'Village',
    image: 'https://assets.shadcnstore.com/shadcnstore.com/stock/marketing/majestic-glacier-landscape.500w.7ea340.avif',
    alt: 'Charming mountain village',
  },
]
```
