# Content 05

> A focus grid with items that can be focused and dimmed.

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

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

## Install

```bash
npx shadcn@latest add https://ui.flexnative.com/r/content-05.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`

## Files

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

```tsx
'use client'

import { AnimatePresence, motion } from 'motion/react'
import { useEffect, useState } from 'react'

export interface Content05Item {
  image: string
  title: string
  description: string
  defaultFocus?: boolean
}

export interface Content05Props {
  title?: string
  description?: string
  items: Content05Item[]
  dimUnfocused?: boolean
  descriptionOnFocus?: boolean
}

// ─── Constants ───────────────────────────────────────────────────────────────

const BREAKPOINTS = { md: 768, lg: 1024 } as const
const COLS = { sm: 1, md: 2, lg: 3 } as const
const GAP_REM = 1

// ─── Hooks ───────────────────────────────────────────────────────────────────

function useColsPerRow(): number | undefined {
  const [cols, setCols] = useState<number | undefined>(undefined)

  useEffect(() => {
    const update = () => {
      const width = window.innerWidth
      if (width >= BREAKPOINTS.lg) setCols(COLS.lg)
      else if (width >= BREAKPOINTS.md) setCols(COLS.md)
      else setCols(COLS.sm)
    }

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

  return cols
}

// ─── Utilities ───────────────────────────────────────────────────────────────

function chunk<T>(array: T[], size: number): T[][] {
  return Array.from({ length: Math.ceil(array.length / size) }, (_, i) =>
    array.slice(i * size, i * size + size),
  )
}

function getItemWidth(
  isFocused: boolean,
  rowSize: number,
  hasRowFocus: boolean,
): string {
  if (rowSize === 1) return '100%'
  const gapTotal = (rowSize - 1) * GAP_REM
  if (!hasRowFocus) return `calc((100% - ${gapTotal}rem) / ${rowSize})`
  const focusedFraction = 2 / (rowSize + 1)
  const otherFraction = (1 - focusedFraction) / (rowSize - 1)
  const fraction = isFocused ? focusedFraction : otherFraction
  return `calc((100% - ${gapTotal}rem) * ${fraction})`
}

// ─── ItemCard ─────────────────────────────────────────────────────────────────

type ItemCardProps = Readonly<{
  item: Content05Item
  isFocused?: boolean
  dimmed?: boolean
  showDescription?: boolean
  width?: string
  onActivate?: () => void
}>

function ItemCard({
  item,
  isFocused = false,
  dimmed = false,
  showDescription = true,
  width,
  onActivate,
}: ItemCardProps) {
  return (
    <button
      type="button"
      onClick={onActivate}
      aria-pressed={isFocused}
      className="group flex shrink-0 cursor-pointer flex-col rounded-xl text-left transition-[width,opacity] duration-500 ease-in-out focus:ring-0 focus:outline-none"
      style={{
        ...(width !== undefined && { width }),
        ...(dimmed && { opacity: 0.4 }),
      }}
    >
      <div className="relative h-64 w-full">
        <img
          src={item.image}
          alt={item.title}
          loading="lazy"
          decoding="async"
          className="group-focus-visible:ring-ring/50 absolute inset-0 size-full rounded-xl object-cover group-focus-visible:ring-2 group-focus-visible:outline-none"
        />
      </div>
      <div className="py-2">
        <h3 className="line-clamp-1 truncate font-semibold">{item.title}</h3>
        <AnimatePresence initial={false}>
          {showDescription && (
            <motion.div
              key="description-wrapper"
              initial={{ height: 0 }}
              animate={{ height: 'auto' }}
              exit={{
                height: 0,
                transition: {
                  duration: 0.15,
                  ease: [0.22, 1, 0.36, 1],
                },
              }}
              transition={{
                duration: 0.35,
                ease: [0.22, 1, 0.36, 1],
                delay: 0.35,
              }}
              className="overflow-hidden"
            >
              <motion.p
                initial={{ opacity: 0, filter: 'blur(6px)' }}
                animate={{ opacity: 1, filter: 'blur(0px)' }}
                exit={{
                  opacity: 0,
                  filter: 'blur(6px)',
                  transition: { duration: 0.5, ease: [0.22, 1, 0.36, 1] },
                }}
                transition={{
                  duration: 0.35,
                  ease: [0.22, 1, 0.36, 1],
                  delay: 0.4,
                }}
                className="text-muted-foreground mt-1 line-clamp-1 text-sm"
              >
                {item.description}
              </motion.p>
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    </button>
  )
}

// ─── Content05 ────────────────────────────────────────────────────────────────

export function Content05({
  title,
  description,
  items,
  dimUnfocused = false,
  descriptionOnFocus = false,
}: Readonly<Content05Props>) {
  const [focusedIndex, setFocusedIndex] = useState(() =>
    items.findIndex((item) => item.defaultFocus),
  )
  const colsPerRow = useColsPerRow()

  const isMobile = colsPerRow === COLS.sm
  const rows = colsPerRow === undefined ? [] : chunk(items, colsPerRow)
  const hasFocusedItem = focusedIndex !== -1

  const toggle = (index: number) =>
    setFocusedIndex((prev) => (prev === index ? -1 : index))

  if (colsPerRow === undefined) return null

  if (isMobile) {
    return (
      <div className="grid grid-cols-1 gap-4">
        {items.map((item) => (
          <ItemCard key={item.title} item={item} />
        ))}
      </div>
    )
  }

  return (
    <div className="flex flex-col gap-4">
      {(title || description) && (
        <div className="flex flex-col gap-1">
          {title && <h2 className="text-2xl font-bold">{title}</h2>}
          {description && (
            <p className="text-muted-foreground">{description}</p>
          )}
        </div>
      )}
      <div className="flex flex-col gap-4">
        {rows.map((row, rowIndex) => {
          const rowStartIndex = rowIndex * colsPerRow
          const hasRowFocus =
            focusedIndex >= rowStartIndex &&
            focusedIndex < rowStartIndex + row.length
          return (
            <div key={rowIndex} className="flex gap-4">
              {row.map((item, colIndex) => {
                const globalIndex = rowStartIndex + colIndex
                const isFocused = focusedIndex === globalIndex
                return (
                  <ItemCard
                    key={item.title}
                    item={item}
                    isFocused={isFocused}
                    dimmed={
                      dimUnfocused &&
                      hasFocusedItem &&
                      focusedIndex !== globalIndex
                    }
                    showDescription={descriptionOnFocus ? isFocused : true}
                    width={getItemWidth(isFocused, row.length, hasRowFocus)}
                    onActivate={() => toggle(globalIndex)}
                  />
                )
              })}
            </div>
          )
        })}
      </div>
    </div>
  )
}
```

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

```tsx
import { Content05, type Content05Props } from './content-05'

export const values = {
  title: 'Our process',
  description: 'From idea to launch—click each phase to see how we work.',
  dimUnfocused: false,
  descriptionOnFocus: true,
  items: [
    {
      image:
        'https://images.unsplash.com/photo-1523275335684-37898b6baf30?q=80&w=1099&auto=format&fit=crop',
      title: 'Discovery',
      description:
        'We start by understanding your goals, your users, and the constraints you face.',
      defaultFocus: true,
    },
    {
      image:
        'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?q=80&w=1170&auto=format&fit=crop',
      title: 'Design',
      description:
        'Clean interfaces and clear flows that people love to use every day.',
    },
    {
      image:
        'https://images.unsplash.com/photo-1587563871167-1ee9c731aefb?q=80&w=1131&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
      title: 'Build',
      description:
        'Solid, accessible code that scales with your product and your team.',
    },
    {
      image:
        'https://images.unsplash.com/photo-1713623069173-3afa89926882?q=80&w=1170&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
      title: 'Test',
      description:
        'We validate everything with real users before launch, so nothing is left to chance.',
    },
    {
      image:
        'https://images.unsplash.com/photo-1625245488600-f03fef636a3c?q=80&w=1074&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
      title: 'Launch',
      description:
        'Go live with confidence, with support and iteration when you need it.',
    },
  ],
} satisfies Content05Props

export function Content05Example() {
  return (
    <Content05
      title={values.title}
      description={values.description}
      dimUnfocused={values.dimUnfocused}
      descriptionOnFocus={values.descriptionOnFocus}
      items={values.items}
    />
  )
}
```
