← Landing
Zubeyr··Sectionsscrollcards

Stacked Cards

Cards that pin under each other as you scroll, leaving a pile of numbered headers. Pure CSS sticky, no JavaScript.

Live demo ↗

Installation

Run the shadcn CLI in a project that already has components.json. Using the @tomei tab? Register the namespace once first.

pnpm dlx shadcn@latest add https://tomei.zubeyr.dev/ui/r/stacked-cards.json
  • →Files land in components/landing.

Usage

example.tsx
import { StackedCards } from '@/components/landing/stacked-cards'

const colors = [
  'bg-white text-neutral-950',
  'bg-[#d4ff3f] text-neutral-950',
  'bg-[#6b4eff] text-white',
  'bg-neutral-950 text-white',
]

const cards = colors.map((className, i) => ({
  title: `Card title ${i + 1}`,
  description: 'A short description of this card. Replace it with your own content.',
  points: ['First point', 'Second point', 'Third point'],
  className,
}))

export default function Demo() {
  return <StackedCards cards={cards} className="px-4 pt-[20vh] md:px-8" />
}

Source code

stacked-cards.tsx
import { cn } from '@/lib/utils'

export type StackedCard = {
  title: string
  description?: string
  points?: string[]
  icon?: React.ReactNode
  className?: string
}

type StackedCardsProps = {
  cards: StackedCard[]
  step?: number
  top?: string
  className?: string
}

const tones = [
  'bg-card text-card-foreground',
  'bg-secondary text-secondary-foreground',
  'bg-primary text-primary-foreground',
]

const pad = (i: number) => String(i + 1).padStart(2, '0')

export function StackedCards({ cards, step = 5.75, top = '20vh', className }: StackedCardsProps) {
  return (
    <div className={cn('flex flex-col', className)}>
      {cards.map((card, index) => (
        <div
          key={card.title}
          className={cn(
            'sticky rounded-t-3xl px-6 md:px-10',
            card.className ?? tones[index % tones.length],
          )}
          style={{
            top: `calc(${top} + ${index * step}em)`,
            paddingBottom: `${(cards.length - index) * step}em`,
          }}
        >
          <div className="flex items-center gap-4 text-4xl font-semibold md:grid md:grid-cols-12 md:gap-10 md:text-6xl">
            <span className="md:col-span-2">({pad(index)})</span>
            <h3 className="py-7 md:col-span-6 md:col-start-6">{card.title}</h3>
            {card.icon && (
              <span className="hidden justify-self-end opacity-60 xl:flex">{card.icon}</span>
            )}
          </div>

          <div className="flex min-h-[30vh] flex-col pt-1 md:grid md:min-h-[40vh] md:grid-cols-12 md:gap-10">
            <div className="flex flex-col gap-5 pt-5 md:col-span-7 md:col-start-6">
              {card.description && (
                <p className="max-w-[40ch] text-base font-medium text-balance opacity-70">
                  {card.description}
                </p>
              )}
              {card.points?.length ? (
                <ul className="divide-y divide-current/15 text-2xl font-bold">
                  {card.points.map((point, i) => (
                    <li key={point} className="flex items-start gap-5 py-2 xl:gap-8 xl:py-3">
                      <span className="font-mono text-base leading-[200%] font-medium opacity-60">
                        {pad(i)}
                      </span>
                      {point}
                    </li>
                  ))}
                </ul>
              ) : null}
            </div>
          </div>
        </div>
      ))}
    </div>
  )
}