Skip to content
    Style: Wireframe

    Blocks · Display

    Split card

    A card with a photo on one side and a heading, a short text and a primary button on the other; the photo goes on top on a narrow card.

    74% of the 2,073 sites in The UI Index have a card (1,540 sites).

    • Start with the quick tour

      Watch the five-minute walkthrough, then copy your first component into a project.

      Watch the tour
    • A rename dialog open over the docs in dark mode

      Dialogs on the native element

      Focus stays inside and returns to where it came from.

      Read the guide

    Both previews render the same markup; the HTML tab below is that markup without a framework.

    Install

    React
    npx shadcn@latest add https://www.themes.dev/ui/r/split-card.json
    Astro
    npx shadcn@latest add https://www.themes.dev/ui/r/astro/split-card.json

    Or copy the files below into your project. They need clsx, tailwind-merge and the components they import, and the themes.dev UI theme. The HTML needs only the compiled CSS.

    split-card.classes.ts
    /** A split card: a photo on one side, a heading, a short text and one primary button on the other. On a narrow
     *  card (a phone) the photo sits on top; from 36rem of card width it goes beside the text. The card sizes itself
     *  (a container), so it also folds when it sits in a narrow column of a wide page. The photo is decorative next to
     *  the heading (alt ""), unless `image.alt` says otherwise. */
    export const splitCard = {
      list: 'm-0 flex list-none flex-col gap-6 px-6 py-12',
      item: '@container',
      card: 'grid overflow-hidden rounded-xl border bg-card text-card-foreground @xl:grid-cols-[minmax(0,9fr)_minmax(0,11fr)]',
      media: 'relative aspect-[2/1] max-h-72 overflow-hidden border-b bg-muted @xl:aspect-auto @xl:border-e @xl:border-b-0 @xl:max-h-none @xl:min-h-64',
      image: 'absolute inset-0 size-full object-cover object-left-top dark:brightness-[.85]',
      body: 'flex min-w-0 flex-col items-start gap-4 p-6 @3xl:p-10',
      title: 'text-2xl font-semibold tracking-[-0.02em] text-balance',
      text: 'max-w-prose text-pretty text-muted-foreground',
      action: 'mt-auto',
    };
    
    export type SplitCardItem = {
      /** At most 60 characters. */
      title: string;
      /** One or two sentences, at most 200 characters. */
      text?: string;
      image: { src: string; alt?: string };
      action: { href: string; label: string };
    };
    
    export type SplitCardContent = {
      /** One to three cards, one under the other. */
      cards: SplitCardItem[];
      /** Names the list of cards for assistive tech. */
      label?: string;
    };
    split-card.tsx
    import { Button } from '@/components/ui/button';
    import { cn } from '@/lib/utils';
    import { splitCard as c, type SplitCardContent } from './split-card.classes';
    
    export type SplitCardProps = SplitCardContent & { className?: string };
    
    /** Cards with a photo on one side and a heading, a short text and a button on the other; the photo goes on top on a narrow card. */
    export function SplitCard({ cards, label = 'Cards', className }: SplitCardProps) {
      return (
        <ul aria-label={label} className={cn(c.list, className)}>
          {cards.map((card) => (
            <li className={c.item} key={card.title}>
              <article className={c.card}>
                <div className={c.media}>
                  <img src={card.image.src} alt={card.image.alt ?? ''} width={1280} height={720} className={c.image} loading="lazy" decoding="async" />
                </div>
                <div className={c.body}>
                  <h2 className={c.title}>{card.title}</h2>
                  {card.text ? <p className={c.text}>{card.text}</p> : null}
                  <Button href={card.action.href} className={c.action}>{card.action.label}</Button>
                </div>
              </article>
            </li>
          ))}
        </ul>
      );
    }