PixelDosa
core

Card

A bordered content container with composable header, title, description, action, content, footer and image regions.

Plated dosa set
Serves two, ready in 12 minutes.

Crisp rice-and-lentil crepe, coconut chutney, and sambar on the side.

Content card
View code
A wooden cabin overlooking a mountain valley at dusk
Ridgeline cabin
Alpine views, sleeps four, 8 nights available.
$210 / night
Listing card
View code
Free
For hobbyists
$0
  • 3 users
  • 10 downloads / month
  • Roster files
Pro
For agencies
$40
  • 3 users
  • Unlimited downloads
  • Fully editable files
  • Custom assets
  • 200+ custom icons
Pricing pair
View code

Installation

bash
npx shadcn@latest add @pixeldosa/card

Requires `cn` at `@/lib/utils`, which `npx shadcn init` creates. CardTitle and CardDescription render `div` elements, not heading tags, matching the wider shadcn/ui convention — supply your own heading element as content if the card needs to participate in the page's document outline. In `orientation="horizontal"`, Card does not auto-arrange its children into an image column and a content column — wrap the header/content/footer parts in your own flex column alongside CardImage.

Usage

import {
  Card,
  CardHeader,
  CardTitle,
  CardDescription,
  CardContent,
  CardFooter,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";

export function OrderSummary() {
  return (
    <Card>
      <CardHeader>
        <CardTitle>Plated dosa set</CardTitle>
        <CardDescription>Serves two, ready in 12 minutes.</CardDescription>
      </CardHeader>
      <CardContent>
        <p className="text-sm text-muted-foreground">
          Crisp rice-and-lentil crepe, coconut chutney, and sambar on the side.
        </p>
      </CardContent>
      <CardFooter className="border-t">
        <Button className="w-full">Add to order</Button>
      </CardFooter>
    </Card>
  );
}

CardAction places a trailing element — a menu trigger, a badge — in the header's second column, aligned to the top-right regardless of how much title or description text wraps:

<CardHeader>
  <CardTitle>Plated dosa set</CardTitle>
  <CardDescription>Serves two, ready in 12 minutes.</CardDescription>
  <CardAction>
    <Button variant="ghost" size="icon" aria-label="More options">
      <MoreIcon />
    </Button>
  </CardAction>
</CardHeader>

CardImage adds a full-bleed image region. In the default vertical orientation it sits first and renders as a top banner; give Card orientation="horizontal" and it becomes a fixed-width, left-rounded side panel instead — wrap the remaining parts in your own flex column so they sit beside it:

<Card orientation="horizontal" className="max-w-lg">
  <CardImage src="/cabin.jpg" alt="A wooden cabin overlooking a mountain valley" />
  <div className="flex flex-1 flex-col justify-between">
    <CardHeader>
      <CardTitle>Ridgeline cabin</CardTitle>
      <CardDescription>Alpine views, sleeps four.</CardDescription>
    </CardHeader>
    <CardFooter>
      <Button size="sm">Reserve now</Button>
    </CardFooter>
  </div>
</Card>

A pricing card needs none of the above — it's the same Card / CardHeader / CardTitle / CardDescription / CardContent / CardFooter parts from the first example, just with a feature list and a price as content. Highlight a tier with a plain className override (border-2 border-primary) rather than a prop, since "highlighted" is a one-off treatment, not a structural variant:

<Card className="border-2 border-primary">
  <CardHeader>
    <CardTitle>Pro</CardTitle>
    <CardDescription>For agencies</CardDescription>
  </CardHeader>
  <CardContent className="flex flex-col gap-4">
    <span className="text-3xl font-semibold">$40</span>
    <ul className="flex flex-col gap-2 text-sm text-muted-foreground">
      <li>Unlimited downloads</li>
      <li>Fully editable files</li>
    </ul>
  </CardContent>
  <CardFooter>
    <Button className="w-full">Subscribe</Button>
  </CardFooter>
</Card>

Props

Every part forwards className and all native props of the element it renders.

PartRendersNotes
CarddivThe bordered container. bg-card / text-card-foreground, 1px border-border, rounded-xl. Takes orientation="vertical" | "horizontal" (default vertical).
CardHeaderdivGrid layout; adds a second column automatically when a CardAction is present.
CardTitledivNot a heading element — nest a real heading tag as its child if the card is part of the page outline.
CardDescriptiondivMuted, small text.
CardActiondivSelf-aligns to the header's top-right.
CardContentdivHorizontal padding only; pairs with Card's vertical py-6.
CardFooterdivAdd the border-t class yourself when the footer should be visually separated.
CardImageimgFull-bleed banner (vertical) or side panel (horizontal) — see above. Requires alt.

Accessibility

  • All parts are plain divs — Card has no interaction semantics of its own, so there is nothing for Radix to manage here.
  • CardTitle deliberately does not render a heading element, matching the wider shadcn/ui convention. If the card's title should appear in the page's heading outline, render a heading element as its child rather than relying on CardTitle itself for that.
  • Interactive cards (a whole card acting as a link or button) are not this component's job — wrap the actionable content in a Button asChild or an anchor instead of adding onClick to Card directly, so keyboard and screen-reader semantics stay correct.
  • CardImage has no default alt — supply one that describes the image's content. If the image is purely decorative (rare for a listing-style card), pass alt="" explicitly rather than omitting the prop.

Engineering Notes

Exposed as eight composable parts rather than a single component with header/footer/title/image props, so a Block composing Card can rearrange or omit regions without fighting a monolithic API. A pricing card, for example, needs zero new Card capability — it composes entirely from the existing header/content/footer parts plus its own feature-list content, which is the intended test of whether something belongs in Card at all versus being a Block built on top of it. CVA now carries exactly one real variant axis, orientation, added because CardImage's rounding genuinely differs between a top banner (vertical) and a side panel (horizontal) — not spending it on density or interactive treatments yet, since no Block currently needs those. Depth uses border-border rather than a shadow, matching the system's dark-mode-first 'borders over shadows' decision documented in the design tokens.

Motion Notes

None. Card is compositional infrastructure with no interaction state of its own — motion belongs on components built on top of it (a future SpotlightCard or an interactive card treatment), not baked into the base primitive, per the pillar rule that motion must earn its place.

Source

The exact file shadcn add writes into your project.

card.tsx
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";

import { cn } from "@/lib/utils";

const cardVariants = cva(
  "group flex overflow-hidden rounded-xl border border-border bg-card text-card-foreground",
  {
    variants: {
      /**
       * `horizontal` gives Card exactly two flex-row children: a `CardImage` and
       * one content column. Header/content/footer parts still stack vertically in
       * that case, but Card does not auto-wrap them — group them in your own
       * `<div className="flex flex-col">` alongside the image, the same way you'd
       * arrange any two-column layout. Card intentionally does not inspect or
       * rearrange its children to do this for you.
       */
      orientation: {
        vertical: "flex-col gap-6 py-6",
        horizontal: "flex-row items-stretch gap-0",
      },
    },
    defaultVariants: {
      orientation: "vertical",
    },
  }
);

export interface CardProps
  extends React.ComponentPropsWithoutRef<"div">,
    VariantProps<typeof cardVariants> {}

/**
 * Bordered content container with optional header/content/footer/image regions.
 * Depth comes from the 1px border token, not a shadow — matches the system's
 * dark-mode-first "borders over shadows" decision so cards read the same weight
 * in both themes.
 */
const Card = React.forwardRef<HTMLDivElement, CardProps>(function Card(
  { className, orientation, ...props },
  ref
) {
  return (
    <div
      ref={ref}
      data-slot="card"
      data-orientation={orientation ?? "vertical"}
      className={cn(cardVariants({ orientation }), className)}
      {...props}
    />
  );
});

const CardHeader = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
  function CardHeader({ className, ...props }, ref) {
    return (
      <div
        ref={ref}
        data-slot="card-header"
        className={cn(
          "grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6",
          "has-[[data-slot=card-action]]:grid-cols-[1fr_auto]",
          className
        )}
        {...props}
      />
    );
  }
);

/**
 * Renders a `div`, not a heading element — matches the wider shadcn/ui convention so
 * this drops into an existing project looking identical to the Card a developer
 * already knows. If the card participates in the page's document outline, render a
 * real heading element as its child instead of relying on this element for semantics.
 */
const CardTitle = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
  function CardTitle({ className, ...props }, ref) {
    return (
      <div
        ref={ref}
        data-slot="card-title"
        className={cn("font-semibold leading-none", className)}
        {...props}
      />
    );
  }
);

const CardDescription = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
  function CardDescription({ className, ...props }, ref) {
    return (
      <div
        ref={ref}
        data-slot="card-description"
        className={cn("text-sm text-muted-foreground", className)}
        {...props}
      />
    );
  }
);

/** Self-aligns to the header's top-right — a menu trigger, a badge, a timestamp. */
const CardAction = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
  function CardAction({ className, ...props }, ref) {
    return (
      <div
        ref={ref}
        data-slot="card-action"
        className={cn(
          "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
          className
        )}
        {...props}
      />
    );
  }
);

const CardContent = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
  function CardContent({ className, ...props }, ref) {
    return (
      <div ref={ref} data-slot="card-content" className={cn("px-6", className)} {...props} />
    );
  }
);

const CardFooter = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
  function CardFooter({ className, ...props }, ref) {
    return (
      <div
        ref={ref}
        data-slot="card-footer"
        className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
        {...props}
      />
    );
  }
);

/**
 * Full-bleed media region. Reads the parent Card's `data-orientation` through the
 * `group` class Card carries, so the same element is a top-rounded banner in the
 * default vertical layout and a fixed-width, left-rounded side panel in horizontal
 * layout — one image component covers both, no separate variant to import.
 */
const CardImage = React.forwardRef<HTMLImageElement, React.ComponentPropsWithoutRef<"img">>(
  function CardImage({ className, ...props }, ref) {
    return (
      <img
        ref={ref}
        data-slot="card-image"
        className={cn(
          "-mt-6 aspect-[4/3] w-full shrink-0 rounded-t-xl object-cover",
          "group-data-[orientation=horizontal]:mt-0 group-data-[orientation=horizontal]:h-auto",
          "group-data-[orientation=horizontal]:w-40 group-data-[orientation=horizontal]:rounded-t-none",
          "group-data-[orientation=horizontal]:rounded-l-xl",
          className
        )}
        {...props}
      />
    );
  }
);

export {
  Card,
  CardHeader,
  CardTitle,
  CardDescription,
  CardAction,
  CardContent,
  CardFooter,
  CardImage,
  cardVariants,
};