# Feature 01

> List of items: select one to reveal its description and media.

A shadcn/ui block from the [@flx](https://ui.flexnative.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/flx/feature-01).

- Page, with a live preview: https://shadcn.exchange/r/flx/feature-01
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/flx/feature-01-light-8f8591f9ab21ed47.png
- Registry item: https://ui.flexnative.com/r/feature-01.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://ui.flexnative.com/r/feature-01.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: `motion`
- Registry items: `button`

## Files

### `registry/blocks/feature/feature-01/feature-01.tsx`

```tsx
'use client'

import * as React from 'react'
import { useState } from 'react'

import { motion, useReducedMotion, type Variants } from 'motion/react'

import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'

export interface Feature01Item {
  id: string
  title: string
  description: string
  media: {
    src: string
    alt: string
  }
}

export interface Feature01Props {
  items: Feature01Item[]
  variant?: 'standard' | 'compact' | 'prominent'
  animation?: 'none' | 'subtle'
}

const variantStyles = {
  standard: {
    container: 'py-12 sm:py-16',
    grid: 'grid-cols-1 gap-10 md:grid-cols-2 md:items-center md:gap-14',
    nav: 'gap-1',
    button: 'py-2.5',
    title: 'text-base',
    titleSelected: 'font-medium',
    desc: 'text-sm',
    media: 'min-h-[320px] md:min-h-[420px]',
    mediaRadius: 'rounded-lg',
  },
  compact: {
    container: 'py-10 sm:py-12',
    grid: 'grid-cols-1 gap-6 md:grid-cols-2 md:items-center md:gap-10',
    nav: 'gap-0.5',
    button: 'py-2',
    title: 'text-sm md:text-base',
    titleSelected: 'font-medium',
    desc: 'text-xs',
    media: 'min-h-[260px] md:min-h-[340px]',
    mediaRadius: 'rounded-md',
  },
  prominent: {
    container: 'py-14 sm:py-20',
    grid: 'grid-cols-1 gap-12 md:grid-cols-2 md:items-center md:gap-16',
    nav: 'gap-1.5',
    button: 'py-3',
    title: 'text-base md:text-lg',
    titleSelected: 'font-semibold',
    desc: 'text-sm md:text-base',
    media: 'min-h-[380px] md:min-h-[480px]',
    mediaRadius: 'rounded-xl',
  },
} as const

const container: Variants = {
  hidden: {},
  visible: { transition: { staggerChildren: 0.1, delayChildren: 0.05 } },
}

const item: Variants = {
  hidden: { opacity: 0, y: 12, filter: 'blur(6px)' },
  visible: {
    opacity: 1,
    y: 0,
    filter: 'blur(0px)',
    transition: { duration: 0.5, ease: [0.22, 1, 0.36, 1] },
  },
}

const navList: Variants = {
  hidden: {},
  visible: { transition: { staggerChildren: 0.06, delayChildren: 0.04 } },
}

const navItem: Variants = {
  hidden: { opacity: 0, y: 12, filter: 'blur(8px)' },
  visible: {
    opacity: 1,
    y: 0,
    filter: 'blur(0px)',
    transition: { duration: 0.55, ease: [0.22, 1, 0.36, 1] },
  },
}

const mediaEnter: Variants = {
  hidden: { opacity: 0, scale: 0.96, filter: 'blur(8px)' },
  visible: {
    opacity: 1,
    scale: 1,
    filter: 'blur(0px)',
    transition: { duration: 0.75, ease: [0.22, 1, 0.36, 1], delay: 0.08 },
  },
}

const viewport = {
  once: true,
  margin: '-80px' as const,
}

function Reveal({
  active,
  variants,
  className,
  children,
}: Readonly<{
  active: boolean
  variants?: Variants
  className?: string
  children: React.ReactNode
}>) {
  if (!active) return <div className={className}>{children}</div>

  return (
    <motion.div variants={variants ?? item} className={className}>
      {children}
    </motion.div>
  )
}

export function Feature01({
  items,
  variant = 'standard',
  animation = 'none',
}: Readonly<Feature01Props>) {
  const [selectedIndex, setSelectedIndex] = useState(0)
  const reduce = useReducedMotion()
  const animate = animation === 'subtle' && !reduce
  const vs = variantStyles[variant]

  if (!items.length) return null

  const renderNavButton = (item: Feature01Item, index: number) => {
    const isSelected = index === selectedIndex

    return (
      <Button
        variant="ghost"
        type="button"
        onClick={() => setSelectedIndex(index)}
        aria-current={isSelected ? 'true' : undefined}
        className={cn(
          'h-auto w-full justify-start rounded-sm text-left font-normal whitespace-normal',
          vs.button,
          'hover:bg-muted/50 hover:opacity-100',
          isSelected && 'text-foreground bg-muted/50',
          !isSelected && 'text-muted-foreground hover:text-foreground',
        )}
      >
        <span className="flex flex-col gap-1">
          <span className={cn(vs.title, isSelected && vs.titleSelected)}>
            {item.title}
          </span>
          <span
            className={cn(
              'grid transition-[grid-template-rows] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
              isSelected ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
            )}
          >
            <span className="overflow-hidden">
              <span
                className={cn(
                  'text-muted-foreground block pb-0.5 transition-opacity duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
                  vs.desc,
                  isSelected ? 'opacity-100' : 'opacity-0',
                )}
              >
                {item.description}
              </span>
            </span>
          </span>
        </span>
      </Button>
    )
  }

  const mediaCrossfade = (
    <div
      className={cn(
        'relative w-full overflow-hidden outline outline-black/10 dark:outline-white/10',
        vs.media,
        vs.mediaRadius,
      )}
    >
      {items.map((item, index) => (
        <div
          key={item.id}
          className={cn(
            'absolute inset-0 transition-opacity duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]',
            index === selectedIndex ? 'opacity-100' : 'opacity-0',
          )}
        >
          <img
            src={item.media.src}
            alt={item.media.alt || item.title}
            loading={index === 0 ? 'eager' : 'lazy'}
            decoding="async"
            fetchPriority={index === 0 ? 'high' : 'low'}
            className="absolute inset-0 size-full object-cover"
          />
        </div>
      ))}
    </div>
  )

  const gridElement = (
    <div className={cn('grid grid-cols-1 md:grid-cols-2', vs.grid)}>
      <nav
        className={cn('flex flex-col justify-center', vs.nav)}
        aria-label="List of items"
      >
        {items.map((item, index) => (
          <div key={item.id}>{renderNavButton(item, index)}</div>
        ))}
      </nav>
      {mediaCrossfade}
    </div>
  )

  const animatedGridElement = (
    <div className={cn('grid grid-cols-1 md:grid-cols-2', vs.grid)}>
      <Reveal active={animate} className="w-full self-center">
        <motion.nav
          className={cn('flex flex-col justify-center', vs.nav)}
          variants={navList}
          aria-label="List of items"
        >
          {items.map((item, index) => (
            <motion.div key={item.id} variants={navItem}>
              {renderNavButton(item, index)}
            </motion.div>
          ))}
        </motion.nav>
      </Reveal>
      <Reveal
        active={animate}
        variants={mediaEnter}
        className="relative min-w-0"
      >
        {mediaCrossfade}
      </Reveal>
    </div>
  )

  if (animate) {
    return (
      <section className="w-full">
        <div
          className={cn('mx-auto w-full max-w-6xl px-6', vs.container)}
        >
          <motion.div
            variants={container}
            initial="hidden"
            whileInView="visible"
            viewport={viewport}
          >
            {animatedGridElement}
          </motion.div>
        </div>
      </section>
    )
  }

  return (
    <section className="w-full">
      <div className={cn('mx-auto w-full max-w-6xl px-6', vs.container)}>
        {gridElement}
      </div>
    </section>
  )
}
```

### `registry/blocks/feature/feature-01/feature-01-example.tsx`

```tsx
import { Feature01, type Feature01Props } from './feature-01'

export const values = {
  variant: 'standard',
  animation: 'subtle',
  items: [
    {
      id: '1',
      title: 'Design',
      description:
        'Clean, accessible components that make your product feel modern and easy to use.',
      media: {
        src: 'https://images.unsplash.com/photo-1695152560286-b09a744834e1?q=80&w=1133&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
        alt: 'alt image',
      },
    },
    {
      id: '2',
      title: 'Components',
      description:
        'Pre-built blocks you can drop into any layout. Customize once, reuse everywhere.',
      media: {
        src: 'https://images.unsplash.com/photo-1683143724745-d66cf5ea5ce7?q=80&w=1202&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
        alt: 'alt image',
      },
    },
    {
      id: '3',
      title: 'Developer Experience',
      description:
        'Built with TypeScript, clear APIs, and documentation that gets you shipping faster.',
      media: {
        src: 'https://images.unsplash.com/photo-1577083862054-7324cd025fa6?q=80&w=1241&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
        alt: 'alt image',
      },
    },
    {
      id: '4',
      title: 'Customization',
      description:
        'Themes, variants, and overrides. Make it look and behave exactly how you need.',
      media: {
        src: 'https://images.unsplash.com/photo-1683143726118-9abaed4e10f9?q=80&w=1062&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
        alt: 'alt image',
      },
    },
    {
      id: '5',
      title: 'Performance',
      description:
        'Optimized for speed and accessibility. Less bundle size, smoother interactions.',
      media: {
        src: 'https://images.unsplash.com/photo-1734552452335-e8b67797bad0?q=80&w=1212&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
        alt: 'alt image',
      },
    },
  ],
} satisfies Feature01Props

export function Feature01Example() {
  return (
    <Feature01
      items={values.items}
      variant={values.variant}
      animation={values.animation}
    />
  )
}
```
