# Content 02

> A grid of media cards with a section header and item details below each image.

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

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

## Install

```bash
npx shadcn@latest add https://ui.flexnative.com/r/content-02.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`, `react-wrap-balancer`
- Registry items: `@flx/dynamic-icon`

## Files

### `registry/blocks/content/content-02/content-02.tsx`

```tsx
'use client'

import * as React from 'react'
import { icons } from 'lucide-react'
import { motion, useReducedMotion, type Variants } from 'motion/react'
import Balancer from 'react-wrap-balancer'

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

import { Icon } from '../../shared/dynamic-icon'

export interface Content02Item {
  title: string
  description: string
  icon: string
  media: {
    src: string
    alt: string
  }
}

export interface Content02Props {
  title: string
  description?: string
  items: Content02Item[]
  variant?: 'standard' | 'compact'
  animation?: 'none' | 'subtle'
}

const variantStyles = {
  standard: {
    container: 'py-12 sm:py-16',
    section: 'gap-10 sm:gap-12',
    header: 'mb-10 gap-3 sm:mb-12',
    title: 'text-2xl sm:text-3xl',
    description: 'max-w-xl text-sm sm:text-base',
    grid: 'grid-cols-1 gap-8 sm:gap-10 md:grid-cols-3',
    card: 'gap-4',
    media: 'aspect-[4/5]',
    mediaRadius: 'rounded-lg',
    icon: 'size-4',
    itemTitle: 'text-base font-medium tracking-tight',
    itemDescription: 'text-sm',
  },
  compact: {
    container: 'py-10 sm:py-12',
    section: 'gap-8',
    header: 'mb-8 gap-2',
    title: 'text-xl sm:text-2xl',
    description: 'max-w-lg text-sm',
    grid: 'grid-cols-1 gap-6 md:grid-cols-3',
    card: 'gap-3',
    media: 'aspect-[4/5]',
    mediaRadius: 'rounded-md',
    icon: 'size-3.5',
    itemTitle: 'text-sm font-medium tracking-tight',
    itemDescription: 'text-xs',
  },
} 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 cardItem: Variants = {
  hidden: { opacity: 0, y: 16, filter: 'blur(8px)' },
  visible: {
    opacity: 1,
    y: 0,
    filter: 'blur(0px)',
    transition: { duration: 0.55, ease: [0.22, 1, 0.36, 1] },
  },
}

const gridContainer: Variants = {
  hidden: {},
  visible: { transition: { staggerChildren: 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 Content02({
  title,
  description,
  items,
  variant = 'standard',
  animation = 'none',
}: Readonly<Content02Props>) {
  const reduce = useReducedMotion()
  const animate = animation === 'subtle' && !reduce
  const vs = variantStyles[variant]

  if (!items.length) return null

  const titleElement = title && (
    <h2
      className={cn(
        'text-foreground font-serif font-normal tracking-tight text-balance',
        vs.title,
      )}
    >
      <Balancer>{title}</Balancer>
    </h2>
  )

  const descriptionElement = description && (
    <p className={cn('text-muted-foreground', vs.description)}>
      <Balancer>{description}</Balancer>
    </p>
  )

  const headerElement = (title || description) && (
    <div className={cn('flex max-w-2xl flex-col', vs.header)}>
      {titleElement}
      {descriptionElement}
    </div>
  )

  const renderCard = (itemData: Content02Item, index: number) => (
    <article className={cn('group/card flex flex-col', vs.card)}>
      <div
        className={cn(
          'group/image relative overflow-hidden outline outline-black/10 dark:outline-white/10',
          vs.media,
          vs.mediaRadius,
        )}
      >
        <img
          src={itemData.media.src}
          alt={itemData.media.alt}
          loading={index === 0 ? 'eager' : 'lazy'}
          decoding="async"
          fetchPriority={index === 0 ? 'high' : 'low'}
          className="size-full object-cover"
        />
      </div>
      <div className="flex flex-col gap-1.5">
        <div className="flex items-center gap-2">
          {itemData.icon && (
            <Icon
              name={itemData.icon as keyof typeof icons}
              className={cn('text-muted-foreground shrink-0', vs.icon)}
            />
          )}
          <h3 className={vs.itemTitle}>{itemData.title}</h3>
        </div>
        {itemData.description && (
          <p className={cn('text-muted-foreground', vs.itemDescription)}>
            {itemData.description}
          </p>
        )}
      </div>
    </article>
  )

  const gridElement = (
    <div className={cn('grid', vs.grid)}>
      {items.map((itemData, index) => (
        <div key={itemData.title + index}>{renderCard(itemData, index)}</div>
      ))}
    </div>
  )

  const animatedGridElement = (
    <motion.div className={cn('grid', vs.grid)} variants={gridContainer}>
      {items.map((itemData, index) => (
        <motion.div key={itemData.title + index} variants={cardItem}>
          {renderCard(itemData, index)}
        </motion.div>
      ))}
    </motion.div>
  )

  const body = (
    <>
      {headerElement}
      {gridElement}
    </>
  )

  const animatedBody = (
    <>
      <Reveal
        active={animate}
        className={cn('flex max-w-2xl flex-col', vs.header)}
      >
        {titleElement}
        {descriptionElement}
      </Reveal>
      <Reveal active={animate}>{animatedGridElement}</Reveal>
    </>
  )

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

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

### `registry/blocks/content/content-02/content-02-example.tsx`

```tsx
import { Content02, type Content02Props } from './content-02'

export const values = {
  title: 'Everything you need to ship',
  description:
    'Design, build, and iterate with blocks made for clarity, not clutter.',
  variant: 'standard',
  animation: 'subtle',
  items: [
    {
      title: 'Design',
      description:
        'Clean, accessible components that make your product feel modern and easy to use.',
      icon: 'Palette',
      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',
      },
    },
    {
      title: 'Components',
      description:
        'Pre-built blocks you can drop into any layout. Customize once, reuse everywhere.',
      icon: 'Layers',
      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',
      },
    },
    {
      title: 'Developer Experience',
      description:
        'Built with TypeScript, clear APIs, and documentation that gets you shipping faster.',
      icon: 'Code',
      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',
      },
    },
  ],
} satisfies Content02Props

export function Content02Example() {
  return (
    <Content02
      title={values.title}
      description={values.description}
      items={values.items}
      variant={values.variant}
      animation={values.animation}
    />
  )
}
```
