# Feature 02

> A process section with a split title/description header and a portrait image flanked by two columns of numbered step cards.

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

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

## Install

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

## Files

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

```tsx
'use client'

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

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

export interface Feature02Step {
  number: string
  title: string
  description: string
}

export interface Feature02Props {
  title: string
  description: string
  image: string
  imageAlt?: string
  steps: Feature02Step[]
  animation?: 'none' | 'subtle'
  variant?: 'standard' | 'compact'
}

const variantStyles = {
  standard: {
    section: 'py-20 sm:py-28',
    title: 'text-3xl sm:text-4xl md:text-5xl',
    description: 'max-w-sm text-sm sm:text-base leading-relaxed',
    header: 'gap-8 sm:gap-16',
    content: 'gap-12 sm:gap-16',
    grid: 'gap-4 sm:gap-6',
    image: 'max-w-sm',
  },
  compact: {
    section: 'py-14 sm:py-20',
    title: 'text-2xl sm:text-3xl md:text-4xl',
    description: 'max-w-xs text-sm leading-relaxed',
    header: 'gap-6 sm:gap-12',
    content: 'gap-10 sm:gap-12',
    grid: 'gap-3 sm:gap-4',
    image: 'max-w-xs',
  },
} as const

const container: Variants = {
  hidden: {},
  visible: { transition: { staggerChildren: 0.08, 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 mediaItem: Variants = {
  hidden: { opacity: 0, scale: 0.96, filter: 'blur(8px)' },
  visible: {
    opacity: 1,
    scale: 1,
    filter: 'blur(0px)',
    transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] },
  },
}

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>
  )
}

function StepCard({
  step,
  animate,
}: Readonly<{ step: Feature02Step; animate: boolean }>) {
  const cardClassName = 'border-border/80 bg-card rounded-2xl border p-5 sm:p-6'

  const content = (
    <>
      <span className="text-muted-foreground font-serif text-lg italic">
        {step.number}
      </span>
      <h3 className="text-foreground mt-3 text-base font-semibold">
        {step.title}
      </h3>
      <p className="text-muted-foreground mt-2 text-sm leading-relaxed">
        <Balancer>{step.description}</Balancer>
      </p>
    </>
  )

  if (!animate) return <div className={cardClassName}>{content}</div>

  return (
    <motion.div
      initial="hidden"
      whileInView="visible"
      viewport={{ once: true, margin: '-40px' }}
      variants={item}
      className={cardClassName}
    >
      {content}
    </motion.div>
  )
}

export function Feature02({
  title,
  description,
  image,
  imageAlt = '',
  steps,
  animation = 'none',
  variant = 'standard',
}: Readonly<Feature02Props>) {
  const reduce = useReducedMotion()
  const animate = animation === 'subtle' && !reduce
  const vs = variantStyles[variant]

  const half = Math.ceil(steps.length / 2)
  const leftSteps = steps.slice(0, half)
  const rightSteps = steps.slice(half)

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

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

  const imageElement = image && (
    <div
      className={cn(
        'relative mx-auto w-full overflow-hidden rounded-3xl shadow-sm outline outline-black/10 dark:outline-white/10',
        vs.image,
      )}
    >
      <img
        src={image}
        alt={imageAlt}
        decoding="async"
        className="aspect-[4/5] w-full object-cover"
      />
    </div>
  )

  const leftColumnElement = leftSteps.length > 0 && (
    <div className={cn('flex flex-col', vs.grid)}>
      {leftSteps.map((step) => (
        <StepCard key={step.number} step={step} animate={animate} />
      ))}
    </div>
  )

  const rightColumnElement = rightSteps.length > 0 && (
    <div className={cn('flex flex-col', vs.grid)}>
      {rightSteps.map((step) => (
        <StepCard key={step.number} step={step} animate={animate} />
      ))}
    </div>
  )

  return (
    <section className="bg-background relative isolate w-full overflow-hidden">
      <motion.div
        className={cn(
          'relative z-10 mx-auto flex max-w-6xl flex-col px-6',
          vs.section,
          vs.content,
        )}
        variants={animate ? container : undefined}
        initial={animate ? 'hidden' : false}
        whileInView={animate ? 'visible' : undefined}
        viewport={{ once: true, margin: '-80px' }}
      >
        <Reveal
          active={animate}
          className={cn(
            'flex flex-col items-start justify-between sm:flex-row sm:items-end',
            vs.header,
          )}
        >
          <div className="max-w-xl">{titleElement}</div>
          {descriptionElement}
        </Reveal>

        <div className="grid grid-cols-1 items-center gap-8 min-[860px]:grid-cols-[1fr_auto_1fr] min-[860px]:gap-6">
          {leftColumnElement}
          <Reveal
            active={animate}
            variants={mediaItem}
            className="order-first min-[860px]:order-0"
          >
            {imageElement}
          </Reveal>
          {rightColumnElement}
        </div>
      </motion.div>
    </section>
  )
}
```

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

```tsx
import { Feature02, type Feature02Props } from './feature-02'

export const values = {
  title: 'Turning Your Product Vision into Interface, Simply.',
  description:
    'Building with Flexnative is a seamless, guided experience designed to make shipping polished UI effortless and fast.',
  image:
    'https://images.unsplash.com/photo-1690848095491-942c798366b8?q=80&w=987&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
  imageAlt:
    'A hand holding a phone showing a product dashboard, with a laptop and monitor displaying UI mockups in the background',
  steps: [
    {
      number: '01',
      title: 'Explore the Library',
      description:
        'Browse hundreds of production-ready blocks across every section type.',
    },
    {
      number: '02',
      title: 'Customize & Theme',
      description:
        'Swap presets, tokens, and copy to match your brand in minutes.',
    },
    {
      number: '03',
      title: 'Compose Your Pages',
      description:
        'Drag blocks into the editor and arrange them into full layouts.',
    },
    {
      number: '04',
      title: 'Ship to Production',
      description: 'Copy the code or install via CLI, ready to deploy today.',
    },
  ],
  animation: 'subtle',
} satisfies Feature02Props

export function Feature02Example() {
  return <Feature02 {...values} />
}
```
