CSS Cards: 16 Examples + the Layout Rules Behind Them

Most CSS card bugs I've run into had nothing to do with shadows or border radius. The markup looked fine with one card on the page and fell apart at twelve.

Below are 16 CSS cards you can try and copy, followed by the layout rules behind them: grids, subgrid, container queries, image cropping and whole-card links that stay accessible.

By Bogdan Sandu · Updated October 28, 2026

Content and editorial cards

Cards that lead somewhere: a blog post, a recipe, an event, a quote and a job ad. Each one keeps a real heading inside, and where the whole card is clickable it is a stretched title link, so the link name stays short and the secondary links and buttons stay usable.

Blog card with a stretched title link

  • ::after inset: 0
  • z-index on secondary links
  • :focus-visible

Click anywhere on the card, or Tab to it.

Only the title is a link, and its ::after pseudo-element covers the whole card, so a click anywhere opens the post while the link name stays the title. The author and tag links sit above the overlay with position: relative and z-index: 1. The keyboard focus ring is drawn on the pseudo-element, so it outlines the full card.

View code
.cd-post {
  position: relative;
  display: flex;
  flex-direction: column;
  width: min(100%, 340px);
  overflow: hidden;
  border: 1px solid #e2e5ec;
  border-radius: 16px;
  background: #fff;
  color: #1d2433;
  box-shadow: 0 1px 2px rgba(16, 24, 40, .06), 0 8px 24px rgba(16, 24, 40, .08);
  transition: box-shadow .2s ease;
}
.cd-post:hover {
  box-shadow: 0 1px 2px rgba(16, 24, 40, .06), 0 16px 36px rgba(16, 24, 40, .14);
}
.cd-post__img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 10;
  object-fit: cover;
}
.cd-post__body {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 18px 20px 20px;
}
.cd-post__title {
  margin: 0;
  font: 600 1.25rem/1.3 "Fraunces", Georgia, serif;
}
.cd-post__link {
  color: inherit;
  text-decoration: none;
}
.cd-post__link::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 16px;
}
.cd-post:hover .cd-post__link,
.cd-post__link:focus-visible {
  text-decoration: underline;
  text-decoration-thickness: 2px;
  text-underline-offset: 4px;
}
.cd-post__link:focus-visible {
  outline: none;
}
.cd-post__link:focus-visible::after {
  outline: 3px solid #1f5bd8;
  outline-offset: -3px;
}
.cd-post__text {
  margin: 0;
  color: #4a5263;
  font-size: .95rem;
  line-height: 1.6;
}
.cd-post__meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px 12px;
  margin: 4px 0 0;
  color: #596173;
  font-size: .85rem;
}
.cd-post__tag,
.cd-post__author {
  position: relative;
  z-index: 1;
  display: inline-flex;
  align-items: center;
  min-height: 24px;
  font-weight: 600;
}
.cd-post__tag {
  padding: 0 10px;
  border-radius: 999px;
  background: #e8efff;
  color: #1846b0;
  text-decoration: none;
}
.cd-post__author {
  color: #1f5bd8;
  text-underline-offset: 2px;
}
.cd-post__tag:hover {
  background: #d6e2ff;
}
.cd-post__tag:focus-visible,
.cd-post__author:focus-visible {
  outline: 2px solid #1f5bd8;
  outline-offset: 2px;
  border-radius: 6px;
}

Recipe card with a meta strip

  • dl / dt / dd
  • grid auto-fit
  • visually hidden link context

Prep time, cook time, servings and difficulty are a description list, so a screen reader reads each value with its label. The strip is a grid that drops from four columns to two when the card gets narrow, and the call to action carries hidden context so it is not just one more generic link.

View code
.cd-rcp {
  width: min(100%, 340px);
  overflow: hidden;
  border-radius: 18px;
  background: #fffaf2;
  color: #2b1d14;
  box-shadow: 0 0 0 1px #eadbc6, 0 12px 28px rgba(80, 48, 20, .12);
}
.cd-rcp__img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}
.cd-rcp__body {
  padding: 18px 20px 20px;
}
.cd-rcp__title {
  margin: 0;
  font: 600 1.35rem/1.25 "Fraunces", Georgia, serif;
}
.cd-rcp__by {
  margin: 4px 0 0;
  color: #6b5544;
  font-size: .9rem;
}
.cd-rcp__meta {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(64px, 1fr));
  margin: 16px 0 0;
  border-block: 1px solid #eadbc6;
}
.cd-rcp__meta div {
  padding: 10px 4px;
  text-align: center;
}
.cd-rcp__meta div + div {
  border-inline-start: 1px solid #eadbc6;
}
.cd-rcp__meta dt {
  color: #6b5544;
  font-size: .72rem;
  font-weight: 600;
  letter-spacing: .06em;
  text-transform: uppercase;
}
.cd-rcp__meta dd {
  margin: 2px 0 0;
  font-weight: 700;
}
.cd-rcp__tags {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin: 14px 0 0;
  padding: 0;
  list-style: none;
}
.cd-rcp__tags li {
  padding: 3px 10px;
  border-radius: 999px;
  background: #e6efe0;
  color: #2f5a22;
  font-size: .8rem;
  font-weight: 600;
}
.cd-rcp__cta {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  margin-top: 16px;
  border-radius: 12px;
  background: #b4441d;
  color: #fff;
  font-weight: 700;
  text-decoration: none;
  transition: background-color .2s ease;
}
.cd-rcp__cta:hover {
  background: #93360f;
}
.cd-rcp__cta:focus-visible {
  outline: 3px solid #2b1d14;
  outline-offset: 3px;
}
.cd-rcp__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

Event card with a date badge

  • time datetime
  • positioned badge
  • flex footer
City skyline beside a lake at sunset, seen from high above

Skyline After Dark: rooftop photo walk

  • Saturday, 7:30 to 10:00 PM
  • Harbor Tower, level 42

From $24

Get tickets

The date badge is a time element with a machine-readable datetime, laid over the photo with absolute positioning. The month and day are separate spans for styling only, so assistive technology still reads one date. The footer pins the price and the ticket link to opposite ends and wraps when space runs out.

View code
.cd-evt {
  width: min(100%, 360px);
  overflow: hidden;
  border-radius: 14px;
  background: #fff;
  color: #13203a;
  box-shadow: 0 0 0 1px #dde3ee, 0 10px 30px rgba(19, 32, 58, .1);
}
.cd-evt__media {
  position: relative;
}
.cd-evt__img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 3 / 2;
  object-fit: cover;
}
.cd-evt__date {
  position: absolute;
  top: 14px;
  left: 14px;
  display: grid;
  place-items: center;
  width: 58px;
  padding: 6px 0 8px;
  border-radius: 10px;
  background: #fff;
  box-shadow: 0 4px 14px rgba(0, 0, 0, .2);
  line-height: 1;
}
.cd-evt__mon {
  color: #c2410c;
  font-size: .75rem;
  font-weight: 700;
  letter-spacing: .08em;
  text-transform: uppercase;
}
.cd-evt__day {
  margin-top: 4px;
  font-size: 1.6rem;
  font-weight: 700;
}
.cd-evt__body {
  padding: 18px 20px 20px;
}
.cd-evt__title {
  margin: 0;
  font-size: 1.15rem;
  line-height: 1.35;
}
.cd-evt__info {
  display: grid;
  gap: 6px;
  margin: 12px 0 0;
  padding: 0;
  color: #475069;
  font-size: .9rem;
  list-style: none;
}
.cd-evt__info li {
  display: flex;
  align-items: center;
  gap: 8px;
}
.cd-evt__info svg {
  flex: none;
  width: 18px;
  height: 18px;
  fill: none;
  stroke: #c2410c;
  stroke-width: 2;
  stroke-linecap: round;
}
.cd-evt__foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-top: 18px;
  padding-top: 16px;
  border-top: 1px solid #e6eaf2;
}
.cd-evt__price {
  margin: 0;
  color: #475069;
  font-size: .9rem;
}
.cd-evt__price strong {
  color: #13203a;
  font-size: 1.25rem;
}
.cd-evt__cta {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: 0 20px;
  border-radius: 999px;
  background: #13203a;
  color: #fff;
  font-weight: 700;
  text-decoration: none;
  transition: background-color .2s ease;
}
.cd-evt__cta:hover {
  background: #c2410c;
}
.cd-evt__cta:focus-visible {
  outline: 3px solid #c2410c;
  outline-offset: 3px;
}

Testimonial card

  • figure / blockquote
  • figcaption
  • role="img" rating

We moved forty client sites over in one weekend. Nobody on the support rota got paged, which has honestly never happened to us before.

Maya OkaforHead of Operations, Brightfold Studio

A quote card is a figure: the blockquote holds the words and the figcaption names the person, which ties the two together for assistive technology. The star row is one element with role="img" and a text label, so it is announced as a rating instead of five separate symbols.

View code
.cd-tst {
  position: relative;
  width: min(100%, 400px);
  margin: 0;
  padding: 30px 28px 26px;
  border-radius: 20px;
  background: #18392b;
  color: #eef6ef;
}
.cd-tst::before {
  content: "\201C";
  position: absolute;
  top: -6px;
  right: 22px;
  color: #2f6b52;
  font: 700 7rem/1 "Fraunces", Georgia, serif;
}
.cd-tst__stars {
  position: relative;
  margin: 0;
  color: #f5c451;
  font-size: 1.05rem;
  letter-spacing: 3px;
}
.cd-tst__quote {
  position: relative;
  margin: 14px 0 0;
}
.cd-tst__quote p {
  margin: 0;
  font: 400 1.2rem/1.55 "Fraunces", Georgia, serif;
}
.cd-tst__who {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-top: 22px;
  padding-top: 18px;
  border-top: 1px solid #2f5a47;
}
.cd-tst__avatar {
  display: grid;
  flex: none;
  place-items: center;
  width: 46px;
  height: 46px;
  border-radius: 50%;
  background: #f5c451;
  color: #18392b;
  font-weight: 700;
}
.cd-tst__who strong {
  display: block;
}
.cd-tst__who strong + span {
  display: block;
  color: #b5d3c1;
  font-size: .9rem;
}

Job listing card with a save button

  • stretched link
  • aria-pressed
  • flex-wrap chips

Senior Product Designer

Kestrel Freight, Lisbon or remote in the EU

  • Full-time
  • Remote friendly
  • €70k to €85k

Own the shipment tracking app end to end, from field research with drivers to the components that ship it.

38 applicants

Click the bookmark to save the job.

The role title stretches over the card, and the save button is a sibling of the link rather than a child, so there is no interactive element nested inside another. The button sits above the overlay with z-index and reports its state with aria-pressed, which also drives the filled bookmark icon.

View code
.cd-job {
  position: relative;
  width: min(100%, 420px);
  padding: 20px;
  border: 1px solid #dfe3ea;
  border-radius: 14px;
  background: #fff;
  color: #17202f;
  transition: border-color .2s ease;
}
.cd-job:hover {
  border-color: #7c8aa3;
}
.cd-job__head {
  display: flex;
  align-items: flex-start;
  gap: 14px;
}
.cd-job__logo {
  display: grid;
  flex: none;
  place-items: center;
  width: 48px;
  height: 48px;
  border-radius: 12px;
  background: #0f766e;
  color: #fff;
  font: 700 1.4rem/1 "Fraunces", Georgia, serif;
}
.cd-job__titles {
  flex: 1;
  min-width: 0;
}
.cd-job__title {
  margin: 0;
  font-size: 1.1rem;
  line-height: 1.3;
}
.cd-job__link {
  color: inherit;
  text-decoration: none;
}
.cd-job__link::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 14px;
}
.cd-job:hover .cd-job__link {
  text-decoration: underline;
  text-underline-offset: 3px;
}
.cd-job__link:focus-visible {
  outline: none;
}
.cd-job__link:focus-visible::after {
  outline: 3px solid #0f766e;
  outline-offset: 2px;
}
.cd-job__co {
  margin: 4px 0 0;
  color: #546077;
  font-size: .9rem;
}
.cd-job__save {
  position: relative;
  z-index: 1;
  display: grid;
  flex: none;
  place-items: center;
  width: 44px;
  height: 44px;
  margin: -6px -6px 0 0;
  padding: 0;
  border: 0;
  border-radius: 10px;
  background: transparent;
  color: #0f766e;
  cursor: pointer;
}
.cd-job__save:hover {
  background: #e6f4f2;
}
.cd-job__save:focus-visible {
  outline: 3px solid #0f766e;
  outline-offset: 0;
}
.cd-job__save svg {
  width: 22px;
  height: 22px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linejoin: round;
}
.cd-job__save[aria-pressed="true"] svg {
  fill: currentColor;
}
.cd-job__tags {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin: 16px 0 0;
  padding: 0;
  list-style: none;
}
.cd-job__tags li {
  padding: 4px 10px;
  border-radius: 6px;
  background: #eef1f6;
  color: #33405a;
  font-size: .82rem;
  font-weight: 600;
}
.cd-job__text {
  margin: 14px 0 0;
  color: #3d4759;
  font-size: .93rem;
  line-height: 1.6;
}
.cd-job__posted {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: 4px 12px;
  margin: 14px 0 0;
  color: #5d6880;
  font-size: .82rem;
}

Product, pricing and profile cards

The cards with the most conversion pressure. Prices carry hidden labels for screen readers, ratings are text and not just stars, and the selectable cards are plain radio buttons styled with :has(:checked).

Product card with rating and add to cart

  • custom property rating
  • hidden price labels
  • role="status"
Art print of orange sandstone cliffs lit by low sun under a cloudy sky 20% off

Canyon Rim at Dusk

Giclée art print, 50 × 70 cm

4.6 out of 5 (212 reviews)

Sale price $64 Regular price $80

Click Add to cart.

The star fill is one custom property: a second row of stars sits on top and its width is the rating divided by five. The number and review count are real text next to it. The sale and regular prices carry hidden labels, and the cart button reports each add through a status region.

View code
.cd-prd {
  --cd-prd-rating: 4.6;
  width: min(100%, 320px);
  overflow: hidden;
  border-radius: 16px;
  background: #fff;
  color: #1c1917;
  box-shadow: 0 0 0 1px #e7e2dc, 0 12px 32px rgba(28, 25, 23, .1);
}
.cd-prd__media {
  position: relative;
  padding: 22px;
  background: #efe9e1;
}
.cd-prd__img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 5 / 7;
  object-fit: cover;
  border: 10px solid #fff;
  box-shadow: 0 10px 24px rgba(28, 25, 23, .25);
}
.cd-prd__badge {
  position: absolute;
  top: 12px;
  right: 12px;
  padding: 4px 10px;
  border-radius: 999px;
  background: #9f1239;
  color: #fff;
  font-size: .8rem;
  font-weight: 700;
}
.cd-prd__body {
  padding: 16px 18px 18px;
}
.cd-prd__title {
  margin: 0;
  font: 600 1.2rem/1.3 "Fraunces", Georgia, serif;
}
.cd-prd__link {
  color: inherit;
  text-decoration: none;
}
.cd-prd__link:hover {
  text-decoration: underline;
  text-underline-offset: 3px;
}
.cd-prd__link:focus-visible {
  outline: 3px solid #9f1239;
  outline-offset: 2px;
  border-radius: 4px;
}
.cd-prd__variant {
  margin: 4px 0 0;
  color: #57534e;
  font-size: .88rem;
}
.cd-prd__rating {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0 6px;
  margin: 10px 0 0;
  font-size: .9rem;
  font-weight: 700;
}
.cd-prd__stars {
  position: relative;
  color: #d6d3d1;
  letter-spacing: 2px;
  white-space: nowrap;
}
.cd-prd__stars::before {
  content: "★★★★★";
}
.cd-prd__stars::after {
  content: "★★★★★";
  position: absolute;
  top: 0;
  left: 0;
  width: calc(var(--cd-prd-rating) / 5 * 100%);
  overflow: hidden;
  color: #b45309;
}
.cd-prd__count {
  color: #57534e;
  font-weight: 400;
}
.cd-prd__buy {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-top: 16px;
}
.cd-prd__price {
  margin: 0;
}
.cd-prd__price strong {
  font-size: 1.4rem;
}
.cd-prd__price s {
  margin-left: 4px;
  color: #6b6560;
}
.cd-prd__add {
  min-height: 44px;
  padding: 0 18px;
  border: 0;
  border-radius: 10px;
  background: #1c1917;
  color: #fff;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
  transition: background-color .2s ease;
}
.cd-prd__add:hover {
  background: #44403c;
}
.cd-prd__add:focus-visible {
  outline: 3px solid #9f1239;
  outline-offset: 3px;
}
.cd-prd--added .cd-prd__add {
  background: #166534;
}
.cd-prd__status {
  min-height: 1.4em;
  margin: 10px 0 0;
  color: #166534;
  font-size: .85rem;
  font-weight: 600;
}
.cd-prd__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

Selectable cards with :has(:checked)

  • :has(:checked)
  • fieldset / legend
  • native radios
Delivery speed

Pick a delivery option, or use the arrow keys.

Each card is a label wrapping a real radio button, so clicking anywhere selects it and the arrow keys move between options. The card styles itself with :has(:checked) and :has(:focus-visible). Browsers without :has() still show the native radio, so the choice is never invisible.

View code
.cd-pick {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr));
  gap: 12px;
  width: min(100%, 640px);
  margin: 0;
  padding: 0;
  border: 0;
  color: #1a2233;
}
.cd-pick__legend {
  margin-bottom: 12px;
  padding: 0;
  font-size: 1.05rem;
  font-weight: 700;
}
.cd-pick__card {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 2px 10px;
  padding: 16px;
  border: 2px solid #d5dbe6;
  border-radius: 14px;
  background: #fff;
  cursor: pointer;
  transition: border-color .2s ease, background-color .2s ease;
}
.cd-pick__card:hover {
  border-color: #8794ad;
}
.cd-pick__input {
  grid-row: span 3;
  width: 20px;
  height: 20px;
  margin: 1px 0 0;
  accent-color: #0b6bcb;
}
.cd-pick__name {
  font-weight: 700;
}
.cd-pick__desc {
  color: #525d72;
  font-size: .88rem;
}
.cd-pick__cost {
  margin-top: 8px;
  font-size: 1.1rem;
  font-weight: 700;
}
.cd-pick__card:has(:checked) {
  border-color: #0b6bcb;
  background: #eef5fd;
}
.cd-pick__card:has(:focus-visible) {
  outline: 3px solid #0b6bcb;
  outline-offset: 3px;
}
.cd-pick__input:focus-visible {
  outline: none;
}
@supports not selector(:has(*)) {
  .cd-pick__input:focus-visible {
    outline: 3px solid #0b6bcb;
    outline-offset: 2px;
  }
}

Profile card with a follow toggle

  • negative margin avatar
  • object-fit crop
  • aria-pressed
Lena with a large backpack, looking over a hazy mountain valley

Lena Kowalczyk

Expedition photographer, Kraków

  • 48 trips
  • 2.1k followers
  • 312 photos
Message

Click Follow.

The avatar is a crop of a larger photo, pulled up over the cover with a negative margin. object-fit: cover fills the circle and a scale with a transform-origin on the person zooms the crop in. The follow button keeps one label and changes state through aria-pressed, which is what screen readers expect from a toggle.

View code
.cd-pro {
  width: min(100%, 320px);
  overflow: hidden;
  border-radius: 20px;
  background: #fff;
  color: #1f2430;
  text-align: center;
  box-shadow: 0 0 0 1px #e1e5ec, 0 16px 36px rgba(31, 36, 48, .12);
}
.cd-pro__cover {
  display: block;
  width: 100%;
  height: 110px;
  object-fit: cover;
  object-position: center 30%;
}
.cd-pro__avatar {
  display: block;
  width: 96px;
  height: 96px;
  margin: -48px auto 0;
  overflow: hidden;
  border: 4px solid #fff;
  border-radius: 50%;
  background: #e1e5ec;
}
.cd-pro__avatar img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transform: scale(2.6);
  transform-origin: 46% 64%;
}
.cd-pro__body {
  padding: 10px 22px 22px;
}
.cd-pro__name {
  margin: 0;
  font-size: 1.2rem;
}
.cd-pro__role {
  margin: 4px 0 0;
  color: #596274;
  font-size: .9rem;
}
.cd-pro__stats {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  margin: 18px 0 0;
  padding: 12px 0;
  border-block: 1px solid #e8ebf0;
  color: #596274;
  font-size: .8rem;
  list-style: none;
}
.cd-pro__stats strong {
  display: block;
  color: #1f2430;
  font-size: 1.1rem;
}
.cd-pro__actions {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
  margin-top: 18px;
}
.cd-pro__follow,
.cd-pro__msg {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  min-height: 44px;
  border-radius: 999px;
  font: inherit;
  font-weight: 700;
  text-decoration: none;
  cursor: pointer;
}
.cd-pro__follow {
  border: 2px solid #be123c;
  background: #be123c;
  color: #fff;
  transition: background-color .2s ease, color .2s ease;
}
.cd-pro__follow svg {
  display: none;
  width: 18px;
  height: 18px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.cd-pro__follow[aria-pressed="true"] {
  background: #fff;
  color: #be123c;
}
.cd-pro__follow[aria-pressed="true"] svg {
  display: block;
}
.cd-pro__msg {
  border: 2px solid #cfd5df;
  color: #1f2430;
}
.cd-pro__msg:hover {
  border-color: #1f2430;
}
.cd-pro__follow:focus-visible,
.cd-pro__msg:focus-visible {
  outline: 3px solid #1f2430;
  outline-offset: 3px;
}

Dashboard and app cards

Cards inside products rather than marketing pages: KPI tiles, a notification with actions, a dark media card and a skeleton placeholder. Motion only runs when the visitor has not asked for reduced motion.

KPI stat cards for a dashboard

  • tabular-nums
  • container query
  • inline SVG sparkline

Monthly revenue

$48,250

Up 12.4% vs September

Active customers

3,912

Up 4.1% vs September

Churn rate

2.8%

Down 0.6 pts vs September

First reply time

1h 42m

Up 9% slower than September

Each tile is its own query container, so the sparkline drops below the number when the tile is narrow, whatever the viewport. Trend colors follow good and bad rather than up and down: falling churn is green. The direction is also written out in hidden text, so color is never the only cue.

View code
.cd-kpi {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(170px, 100%), 1fr));
  gap: 14px;
  width: min(100%, 780px);
}
.cd-kpi__card {
  container: cd-kpi / inline-size;
  padding: 16px 18px;
  border: 1px solid #e0e4eb;
  border-radius: 12px;
  background: #fff;
  color: #141a26;
}
.cd-kpi__label {
  margin: 0;
  color: #525c70;
  font-size: .85rem;
  font-weight: 600;
}
.cd-kpi__row {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  justify-content: space-between;
  gap: 6px 10px;
  margin-top: 6px;
}
.cd-kpi__value {
  margin: 0;
  font-size: 1.7rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  line-height: 1.1;
}
.cd-kpi__spark {
  width: 72px;
  height: 28px;
  fill: none;
  stroke: #15803d;
  stroke-width: 2.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.cd-kpi__spark--bad {
  stroke: #b42318;
}
@container cd-kpi (max-width: 200px) {
  .cd-kpi__spark {
    width: 100%;
  }
}
.cd-kpi__delta {
  margin: 10px 0 0;
  font-size: .82rem;
  font-weight: 700;
}
.cd-kpi__delta span:last-child {
  color: #5f687a;
  font-weight: 400;
}
.cd-kpi__delta--good {
  color: #15803d;
}
.cd-kpi__delta--bad {
  color: #b42318;
}
.cd-kpi__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

Notification card with actions and undo

  • hidden attribute
  • role="status"
  • focus management

Invoice INV-2048 is overdue

Harbor and Pine has not paid $1,240.00. It was due on 20 October.

Send reminder

Click Dismiss, then Undo.

Dismissing the card hides it with the hidden attribute, announces the change in a status region and moves focus to the Undo button, so keyboard users are not dropped at the top of the page. Undo brings the card back and returns focus to where it was.

View code
.cd-ntf {
  display: grid;
  align-content: center;
  width: min(100%, 440px);
  min-height: 210px;
}
.cd-ntf__card {
  display: flex;
  gap: 14px;
  padding: 18px;
  border: 1px solid #ecdcc0;
  border-inline-start: 5px solid #d97706;
  border-radius: 12px;
  background: #fff;
  color: #1f1a12;
  box-shadow: 0 10px 28px rgba(60, 40, 10, .1);
}
.cd-ntf__card[hidden] {
  display: none;
}
.cd-ntf__icon {
  display: grid;
  flex: none;
  place-items: center;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: #fef3c7;
  color: #92400e;
}
.cd-ntf__icon svg {
  width: 22px;
  height: 22px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.cd-ntf__body {
  flex: 1;
  min-width: 0;
}
.cd-ntf__top {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: 2px 12px;
}
.cd-ntf__title {
  margin: 0;
  font-size: 1rem;
  line-height: 1.35;
}
.cd-ntf__time {
  color: #6b6252;
  font-size: .8rem;
}
.cd-ntf__text {
  margin: 6px 0 0;
  color: #4a4335;
  font-size: .92rem;
  line-height: 1.55;
}
.cd-ntf__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 14px;
}
.cd-ntf__primary,
.cd-ntf__dismiss,
.cd-ntf__undo {
  display: inline-flex;
  align-items: center;
  min-height: 40px;
  padding: 0 16px;
  border-radius: 8px;
  font: inherit;
  font-size: .9rem;
  font-weight: 700;
  text-decoration: none;
  cursor: pointer;
}
.cd-ntf__primary {
  background: #92400e;
  color: #fff;
}
.cd-ntf__primary:hover {
  background: #78350f;
}
.cd-ntf__dismiss,
.cd-ntf__undo {
  border: 1px solid #d6cbb6;
  background: #fff;
  color: #1f1a12;
}
.cd-ntf__dismiss:hover,
.cd-ntf__undo:hover {
  background: #faf5ea;
}
.cd-ntf__primary:focus-visible,
.cd-ntf__dismiss:focus-visible,
.cd-ntf__undo:focus-visible {
  outline: 3px solid #92400e;
  outline-offset: 2px;
}
.cd-ntf__after {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 12px;
  margin-top: 12px;
}
.cd-ntf__status {
  margin: 0;
  color: #4a4335;
  font-size: .9rem;
}
.cd-ntf__undo[hidden] {
  display: none;
}

Dark media card for a podcast episode

  • dark palette tokens
  • aria-pressed play button
  • progress bar
Orange jellyfish trailing long tentacles through deep blue water

Why jellyfish are older than dinosaurs

Deep Signal, episode 41 38 min

12:40 of 38:05

12:4038:05

Click the play button.

A dark card needs its own steps of lightness: the card sits one step above the page, the border one step above the card, and muted text stays above 4.5:1. The play button is a toggle with a fixed label, and the elapsed time is a native progress element with a text equivalent.

View code
.cd-pod {
  display: grid;
  grid-template-columns: minmax(84px, 120px) 1fr;
  gap: 16px;
  width: min(100%, 460px);
  padding: 16px;
  border: 1px solid #2a3346;
  border-radius: 18px;
  background: #151b27;
  color: #eef1f7;
}
.cd-pod__art {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 1;
  object-fit: cover;
  object-position: 70% 40%;
  border-radius: 12px;
}
.cd-pod__body {
  display: flex;
  flex-direction: column;
  min-width: 0;
}
.cd-pod__title {
  margin: 0;
  font-size: 1.05rem;
  line-height: 1.35;
}
.cd-pod__show {
  margin: 6px 0 0;
  color: #a7b0c2;
  font-size: .85rem;
}
.cd-pod__player {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-top: auto;
  padding-top: 14px;
}
.cd-pod__play {
  display: grid;
  flex: none;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: #fb923c;
  color: #1a1206;
  cursor: pointer;
  transition: background-color .2s ease;
}
.cd-pod__play:hover {
  background: #fdba74;
}
.cd-pod__play:focus-visible {
  outline: 3px solid #fdba74;
  outline-offset: 3px;
}
.cd-pod__play svg {
  width: 22px;
  height: 22px;
  fill: currentColor;
}
.cd-pod__i-pause,
.cd-pod__play[aria-pressed="true"] .cd-pod__i-play {
  display: none;
}
.cd-pod__play[aria-pressed="true"] .cd-pod__i-pause {
  display: block;
}
.cd-pod__track {
  flex: 1;
  min-width: 0;
}
.cd-pod__bar {
  display: block;
  width: 100%;
  height: 6px;
  border: 0;
  border-radius: 3px;
  background: #2a3346;
  color: #fb923c;
  accent-color: #fb923c;
  appearance: none;
}
.cd-pod__bar::-webkit-progress-bar {
  border-radius: 3px;
  background: #2a3346;
}
.cd-pod__bar::-webkit-progress-value {
  border-radius: 3px;
  background: #fb923c;
}
.cd-pod__bar::-moz-progress-bar {
  border-radius: 3px;
  background: #fb923c;
}
.cd-pod__time {
  display: flex;
  justify-content: space-between;
  margin: 6px 0 0;
  color: #a7b0c2;
  font-size: .75rem;
  font-variant-numeric: tabular-nums;
}

Skeleton loading card

  • aria-busy
  • opt-in pulse animation
  • role="status"

Loading trail details

Click Finish loading to swap in the content.

The placeholder blocks match the size of the real content, so nothing jumps when it arrives. The card carries aria-busy while it loads and a status line says what is happening. The pulse only runs inside prefers-reduced-motion: no-preference; everyone else sees still grey blocks.

View code
.cd-skl {
  display: grid;
  justify-items: center;
  gap: 10px;
  width: min(100%, 320px);
}
.cd-skl__card {
  width: 100%;
  overflow: hidden;
  border: 1px solid #e1e5ec;
  border-radius: 16px;
  background: #fff;
  color: #18202e;
}
.cd-skl__ghost,
.cd-skl__real {
  display: grid;
  gap: 10px;
  padding-bottom: 18px;
}
.cd-skl__ghost[hidden],
.cd-skl__real[hidden] {
  display: none;
}
.cd-skl__blk {
  display: block;
  height: 14px;
  margin-inline: 18px;
  border-radius: 6px;
  background: #e4e8ef;
}
.cd-skl__blk--img {
  height: auto;
  margin: 0 0 6px;
  aspect-ratio: 16 / 10;
  border-radius: 0;
}
.cd-skl__blk--title {
  width: 60%;
  height: 20px;
}
.cd-skl__blk--short {
  width: 40%;
}
.cd-skl__img {
  display: block;
  width: 100%;
  height: auto;
  margin-bottom: 6px;
  aspect-ratio: 16 / 10;
  object-fit: cover;
}
.cd-skl__title {
  margin: 0 18px;
  font-size: 1.1rem;
  line-height: 1.3;
}
.cd-skl__text {
  margin: 0 18px;
  color: #4d576a;
  font-size: .92rem;
  line-height: 1.5;
}
.cd-skl__status {
  margin: 4px 0 0;
  color: #4d576a;
  font-size: .88rem;
}
.cd-skl__btn {
  min-height: 44px;
  padding: 0 18px;
  border: 1px solid #c4cbd8;
  border-radius: 10px;
  background: #fff;
  color: #18202e;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
.cd-skl__btn:hover {
  background: #f1f4f8;
}
.cd-skl__btn:focus-visible {
  outline: 3px solid #2856c9;
  outline-offset: 2px;
}
@keyframes cd-skl-pulse {
  50% {
    opacity: .45;
  }
}
@media (prefers-reduced-motion: no-preference) {
  .cd-skl__blk {
    animation: cd-skl-pulse 1.4s ease-in-out infinite;
  }
}

Card layout techniques

The layout rules from the guide below, as working demos: an auto-fit grid whose titles and buttons line up through subgrid, a card that switches between stacked and horizontal with a container query, and image boxes held by aspect-ratio and object-fit.

Auto-fit card grid with subgrid rows

  • repeat(auto-fit, minmax())
  • grid-template-rows: subgrid
  • @supports fallback

The grid has no breakpoints: auto-fit with minmax(min(190px, 100%), 1fr) decides the column count. Inside @supports, each list item and card spans four rows and adopts them with grid-template-rows: subgrid, so every title, text block and link lines up across a row. Without subgrid the cards fall back to a flex column with the link pushed down by margin-top: auto.

View code
.cd-sg {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(190px, 100%), 1fr));
  gap: 18px;
  width: min(100%, 760px);
  margin: 0;
  padding: 0;
  list-style: none;
}
.cd-sg__item {
  display: grid;
}
.cd-sg__card {
  display: flex;
  flex-direction: column;
  overflow: hidden;
  border: 1px solid #dfe3ea;
  border-radius: 14px;
  background: #fff;
  color: #1a2130;
}
.cd-sg__img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}
.cd-sg__title {
  margin: 0;
  padding: 14px 16px 0;
  font: 600 1.1rem/1.3 "Fraunces", Georgia, serif;
}
.cd-sg__text {
  margin: 0;
  padding: 8px 16px 0;
  color: #4b5568;
  font-size: .9rem;
  line-height: 1.55;
}
.cd-sg__cta {
  align-self: start;
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  margin: auto 16px 0;
  padding: 8px 0 12px;
  color: #0b57a4;
  font-weight: 700;
  text-underline-offset: 3px;
}
.cd-sg__cta:focus-visible {
  outline: 3px solid #0b57a4;
  outline-offset: 2px;
  border-radius: 4px;
}
.cd-sg__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
@supports (grid-template-rows: subgrid) {
  .cd-sg__item,
  .cd-sg__card {
    display: grid;
    grid-row: span 4;
    grid-template-rows: subgrid;
    row-gap: 0;
  }
  .cd-sg__cta {
    margin-top: 0;
  }
}

Horizontal card that stacks with a container query

  • container-type: inline-size
  • @container
  • range input
Terraced green hillside dropping to a calm sea along a rocky coast

One coast road, five stops worth the detour

Terraced olive groves, a fishing harbor with two tables and the lookout where everyone stops for the same photo.

Road trips 8 min read

Drag the slider to resize the card slot.

The card never looks at the viewport. Its slot is a named query container, and at 520px or wider the card switches from stacked to image beside text. Move the slider to resize the slot and watch the card change at the same page width.

View code
.cd-hz {
  display: grid;
  gap: 16px;
  width: min(100%, 720px);
}
.cd-hz__slot {
  container: cd-hz / inline-size;
  justify-self: center;
  width: 100%;
  min-width: min(100%, 260px);
  padding: 10px;
  border: 2px dashed #b8c0ce;
  border-radius: 20px;
}
.cd-hz__card {
  display: grid;
  overflow: hidden;
  border-radius: 14px;
  background: #fff;
  color: #1b2230;
  box-shadow: 0 10px 28px rgba(27, 34, 48, .1);
}
.cd-hz__img {
  display: block;
  width: 100%;
  height: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
}
.cd-hz__body {
  padding: 16px 18px 18px;
}
.cd-hz__title {
  margin: 0;
  font: 600 1.2rem/1.3 "Fraunces", Georgia, serif;
}
.cd-hz__text {
  margin: 8px 0 0;
  color: #4b5467;
  font-size: .92rem;
  line-height: 1.6;
}
.cd-hz__meta {
  margin: 12px 0 0;
  color: #5c6578;
  font-size: .82rem;
  font-weight: 600;
}
@container cd-hz (min-width: 520px) {
  .cd-hz__card {
    grid-template-columns: 42% 1fr;
  }
  .cd-hz__img {
    aspect-ratio: auto;
    min-height: 210px;
  }
  .cd-hz__body {
    align-self: center;
    padding: 22px 24px;
  }
}
.cd-hz__ctl {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 10px;
  color: #1b2230;
  font-size: .9rem;
  font-weight: 600;
}
.cd-hz__range {
  width: min(240px, 60vw);
  min-height: 24px;
  accent-color: #0e7490;
}
.cd-hz__range:focus-visible {
  outline: 3px solid #0e7490;
  outline-offset: 3px;
}
.cd-hz__out {
  min-width: 3.5em;
  font-variant-numeric: tabular-nums;
}

Card media with aspect-ratio and object-fit

  • aspect-ratio
  • object-fit: cover / contain
  • :has(:checked)
Narrow brick alley with fire escapes, leading toward office towers

Back alleys worth a detour

Murals, fire escapes and the cafe with no sign.

Ratio
Fit

Change the ratio and the fit.

The image box is set by aspect-ratio alone, so it reserves its space before the file loads and every card in a set gets the same shape. Switch the ratio and the fit: cover crops to fill the box, contain shows the whole photo with bands. The controls are plain radios: the card reads them with :has(:checked), so there is no script, and browsers without :has() keep the 16:9 cover default.

View code
.cd-ar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 24px;
  width: min(100%, 640px);
}
.cd-ar__card {
  width: min(100%, 300px);
  overflow: hidden;
  border-radius: 14px;
  background: #fff;
  color: #1c2331;
  box-shadow: 0 0 0 1px #e0e4eb, 0 10px 26px rgba(28, 35, 49, .1);
}
.cd-ar__img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  background: #262c38;
}
.cd-ar:has(#cd-ar-43:checked) .cd-ar__img {
  aspect-ratio: 4 / 3;
}
.cd-ar:has(#cd-ar-11:checked) .cd-ar__img {
  aspect-ratio: 1 / 1;
}
.cd-ar:has(#cd-ar-34:checked) .cd-ar__img {
  aspect-ratio: 3 / 4;
}
.cd-ar:has(#cd-ar-contain:checked) .cd-ar__img {
  object-fit: contain;
}
.cd-ar__body {
  padding: 14px 16px 16px;
}
.cd-ar__title {
  margin: 0;
  font-size: 1.05rem;
}
.cd-ar__text {
  margin: 6px 0 0;
  color: #4d5669;
  font-size: .9rem;
}
.cd-ar__controls {
  display: grid;
  gap: 14px;
}
.cd-ar__set {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin: 0;
  padding: 0;
  border: 0;
}
.cd-ar__set legend {
  margin-bottom: 6px;
  padding: 0;
  color: #1c2331;
  font-size: .85rem;
  font-weight: 700;
}
.cd-ar__set label {
  position: relative;
}
.cd-ar__set input {
  position: absolute;
  opacity: 0;
  width: 1px;
  height: 1px;
}
.cd-ar__set span {
  display: inline-flex;
  align-items: center;
  min-height: 36px;
  padding: 0 14px;
  border: 1px solid #c3cad7;
  border-radius: 8px;
  background: #fff;
  color: #1c2331;
  font-family: ui-monospace, Consolas, monospace;
  font-size: .85rem;
  cursor: pointer;
}
.cd-ar__set label:hover span {
  border-color: #6b7891;
}
.cd-ar__set input:checked + span {
  border-color: #1c2331;
  background: #1c2331;
  color: #fff;
}
.cd-ar__set input:focus-visible + span {
  outline: 3px solid #2856c9;
  outline-offset: 2px;
}

What Is a CSS Card and What Parts Does It Have?

Take away the shadow and the rounded corners and you still have a card, as long as the related content sits in one bounded container. Styled, aligned and sized together, that container is what separates a card from a loose stack of elements.

Cards repeat. A page rarely shows just one, so every decision about the container (width, spacing, shadow) has to survive being copied a dozen times inside a card-based layout.

Parts of a card

Each part of a full card maps to a specific element.

  • The media goes in an img or picture element, usually shown first
  • The title is a heading, and whether it’s an h2, h3 or h4 depends on the page outline
  • A p element holds the summary text
  • The footer carries metadata like date, author or price
  • The action is an a element (or a button) that leads somewhere

Source order matters more than it looks. Nomensa’s guidance on accessible cards puts the heading first in the HTML and uses Flexbox to show the image above it, so the heading still introduces the group for screen readers.

A minimal card looks like this:

<article class="card">
  <img class="card-img" src="cable-car.jpg" alt="Cable car over a valley">
  <div class="card-body">
    <h3 class="card-title">Cable car routes</h3>
    <p class="card-text">Three scenic lines, ranked by view and queue time.</p>
    <a class="card-action" href="/cable-cars">See routes</a>
  </div>
</article>

Which HTML element should wrap a card?

For a self-contained item like a blog post or a product, article fits. It makes sense on its own, which is the whole test.

Cards that form a list are better as an li inside a ul, since screen readers then announce how many items the list holds. The two combine well: the li becomes the grid item and the article sits inside it.

A plain div is the fallback for purely visual groupings like stat tiles and promo boxes.

Card variants and the parts each uses

  • A blog post card usually carries an image, title, excerpt, date and a “Read more” link
  • Product cards swap in a name, price, rating and buy button
  • Profile cards hold an avatar, name, role and social links
  • Pricing cards need a plan name, price, feature list and call to action
  • A horizontal card can be any of the above, with the image beside the text instead of above it

Product cards carry the most conversion pressure of the lot, and the details of designing a product card deserve their own guide.

How to Create a CSS Card Step by Step

A basic card goes together quickly once the markup is in place.

  1. Write the markup with the structure shown above.
  2. Style the container with a background, a 1px border, a border-radius and a box-shadow.
  3. Put padding on .card-body, not on .card, so the image runs edge to edge.
  4. Add overflow: hidden to the card so the image follows the rounded corners.
  5. Give the link or button a visible background, its own padding and a hover state.
.card {
  max-width: 22rem;
  background: #fff;
  border: 1px solid #e3e6ea;
  border-radius: 12px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
  overflow: hidden;
}

.card-body {
  padding: 1rem 1.25rem;
}

Shadow, radius and border values

The box-shadow shorthand takes horizontal offset, vertical offset, blur, spread and color, in that order.

Keep the color low in opacity. A box shadow generator lets you preview offset and blur before you commit to a value.

Radius follows the same logic. Pick one value and match it to your buttons and inputs. Try a few options in a border radius generator before you fix the number across every card.

Custom properties for card variants

Store the values that change between variants in custom properties. A featured card then needs one line of CSS instead of a copy of the whole rule.

.card {
  --card-bg: #fff;
  --card-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
  background: var(--card-bg);
  box-shadow: var(--card-shadow);
}

.card--featured {
  --card-bg: #fff8e6;
}

A fixed width on .card is the mistake that shows up most. It fights the grid track and breaks reflow on narrow screens, so use max-width and let the layout size the card.

The Computed tab in Chrome DevTools shows the box model for any card. Shadows take no layout space, so a large spread never pushes neighboring cards around.

Flexbox vs CSS Grid for Card Layouts

Use CSS Grid for the grid of cards and Flexbox for the inside of each card.

Grid controls rows and columns together, so cards line up in both directions. Flexbox runs on one axis, which suits the stack of image, text and button inside a single card.

MethodBest forWhere it breaks
Flexbox with flex-wrapSmall sets, centered rowsWith flex-grow, the last row stretches
Grid with auto-fitFluid grids with many cardsA lone card stretches across the row
Grid with auto-fillConsistent card width at any countEmpty tracks keep their space
Grid with subgridAligned image, title and button rowsNeeds a row span that matches the children

The difference between auto-fit and auto-fill only shows when a row holds fewer cards than the container could fit. MDN describes auto-fit as identical to auto-fill, except that empty tracks collapse once the items are placed.

That makes auto-fill the better default for card grids, which goes against the usual advice. Two cards in a wide container keep their normal width instead of stretching across the row.

.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(240px, 100%), 1fr));
  gap: 1.5rem;
}

Wrapping the minimum in min() stops the column from overflowing a narrow screen. The result is responsive design with zero breakpoints.

How to get equal-height cards

Cards in one grid row share the same height by default, because the default align-items: normal behaves like stretch for grid items.

The visible gap is inside the card. Short text leaves the button floating mid-card.

  • Set the card to display: flex; flex-direction: column, then give the body flex: 1
  • Or skip the growing body and push the action down with margin-top: auto

How subgrid aligns inner card rows

Subgrid became Baseline Newly available on 15 September 2023 and Baseline Widely available on 15 March 2026, according to Web Platform DX data. Firefox 71 shipped it in December 2019, Safari 16 followed in September 2022, and Chrome 117 and Edge 117 arrived in September 2023.

Without subgrid, a long title in one card pushes its button down while a neighbor’s button stays high. With subgrid, each card inherits the parent’s row tracks, so titles, text and buttons line up across the whole row.

Each card spans a fixed number of rows, set with grid-row: span 3, and grid-template-rows: subgrid on the card does the inheriting. The parent’s gap then controls the space between card sections.

SitePoint’s layout tutorial flags a catch. Putting container-type on the card itself breaks track inheritance, so the declaration moves to an inner wrapper.

Survey comments in the State of CSS 2024, as analyzed by Patrick Brosset, also mention bugs and performance problems with subgrid. Test it in every browser you support.

Media Queries or Container Queries for Responsive Cards?

Use container queries for the card itself and media queries for page-level layout.

Media queries read the viewport width. A container query reads the width of the card’s parent, so one card design works in a sidebar, a three-column grid and a full-width hero without separate overrides.

.card-slot {
  container-type: inline-size;
  container-name: card;
}

@container card (min-width: 480px) {
  .card {
    display: grid;
    grid-template-columns: 200px 1fr;
  }
}

A card cannot query its own width. The container is always an ancestor, so the grid cell or a wrapper element gets container-type: inline-size, and the card’s rules sit inside @container.

The page shell, navigation and number of columns stay with media queries. A card that switches between stacked and horizontal belongs to a container query. User preferences like reduced motion go back to media queries.

Chrome’s container query case studies describe Tokopedia finding its use case by looking for truncated text (ellipsis) on its site. Cards whose titles get cut off in narrow slots are the same signal.

As of the May 2026 Baseline digest, the support picture looks like this.

  • Container queries landed in all major browser engines in 2023 (web.dev)
  • Container queries became Baseline Widely available in August 2025 (web.dev August 2025 Baseline digest)
  • Name-only container queries became Baseline Newly available with Chrome 148 in May 2026 (web.dev, 2026)
  • Container style queries for custom properties became Baseline Newly available with Firefox 151 in May 2026 (web.dev, 2026)

Older tutorials still describe style queries as a Chrome and Edge feature. The May 2026 status replaces that claim, though Drupal core still waits for Firefox ESR before adopting them broadly, as The Droptimes reported in 2026.

Container queries have a cost too. Brosset’s read of the State of CSS 2024 comments found respondents calling them too complex.

Keep the non-queried layout usable wherever style queries are unsupported. MDN’s @container table and Can I use show current support before you ship.

How to Size and Crop Card Images

Give every card image the same aspect-ratio and crop it with object-fit: cover. The image box then stays identical across cards, whatever proportions the source files have.

.card-img {
  display: block;
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
}

An aspect ratio is the width divided by the height, so 16 / 9 gives a wide box and 1 / 1 a square.

With cover the image fills the box and the overflow gets cropped. contain shows the whole image but leaves empty bands where proportions differ. object-position moves the focal point, for example center top for portraits.

Cropping has a cost. cover can slice off faces or product edges, so test the worst image in the set, not the best one.

A Cumulative Layout Shift score of 0.1 or less counts as good, according to web.dev, and images without dimensions are one of the causes it names.

Width and height attributes on the img, or the aspect-ratio above, reserve the space before the file arrives.

Add loading="lazy" to card images below the fold. Nomensa lists it next to file-size optimization as the basic image step for cards.

Keep it off the first visible row. Shopify’s theme documentation names lazy-loading the largest contentful paint image among the most common and damaging performance mistakes, and a first-row card image is often that element.

Cards without an image need a different top edge.

:has() lets the card style itself by its content: .card:not(:has(img)) { padding-top: 1.25rem; }

web.dev’s State of CSS 2024 recap listed :has() as Baseline Newly available. That label is out of date, since it reached Baseline Widely available in June 2026.

Hover, Focus and Transition States on Cards

Animate transform and box-shadow with a short transition, and give keyboard focus the same visual change as mouse hover.

.card {
  transition: transform 0.2s ease, box-shadow 0.2s ease;
}

@media (hover: hover) {
  .card:hover {
    transform: translateY(-4px);
    box-shadow: 0 10px 24px rgba(0, 0, 0, 0.16);
  }
}

.card:focus-within {
  box-shadow: 0 10px 24px rgba(0, 0, 0, 0.16);
}

.card-action:focus-visible {
  outline: 3px solid #1a5fb4;
  outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
  .card {
    transition: none;
  }
}

Using transform lifts the card without shifting its neighbors. Anything much longer than 0.2s to 0.3s feels sluggish on repeated hovers, so keep the transition short. :focus-within fires when an inner link has keyboard focus, and prefers-reduced-motion removes movement for people who ask for less of it.

:focus-visible reached Baseline Widely available on 14 September 2024, according to Web Platform DX. It draws the ring for keyboard focus and follows the browser’s own rules about when mouse focus needs one.

Draw that ring with outline, not with a shadow. Forced-colors mode drops shadows but keeps outlines.

Touch screens have no hover, so a hover-only cue does nothing there. The (hover: hover) query above keeps the lift on devices that can use it.

Flashier card hover effects such as image zoom or tilt follow the same pattern with different values.

A CSS transition generator helps compare easing curves before you settle on one.

How to Make a Whole Card Clickable Without Breaking Accessibility

Make the title the link and stretch it over the card with an ::after pseudo-element. The card gets a short link name and a full-size click area.

.card {
  position: relative;
}

.card-title a::after {
  content: "";
  position: absolute;
  inset: 0;
}

.card-meta a {
  position: relative;
  z-index: 1;
}

You can wrap the card in an anchor, stretch a title link, or add a JavaScript click handler. CSS-Tricks walks through all three.

Sources disagree on the first one. Some card tutorials recommend wrapping the card in an anchor when it has a single destination, while CSS-Tricks and Nomensa warn that screen readers then read the full card text as one link, and HTML does not allow a second link nested inside it.

The gap comes down to card content. A card with one destination tolerates the long name, and a card with author and tag links does not.

Wrapping the card in an anchor

This has the simplest markup. No JavaScript, no z-index, and one link means one focus stop.

The downside is that screen readers announce the entire card as the link name. Any second link inside the card also becomes invalid nested interactive content.

Stretching the title link

Pure CSS, and the link name is just the title. The title link keeps its normal focus and hover behavior, and secondary links stay clickable when you lift them above the overlay with z-index.

The overlay blocks text selection, though, and the stacking order needs care as the card grows.

Adding a JavaScript click handler

The markup stays semantic, with a real title link and no pseudo-element stacking.

But you’ve added a script dependency for what is really a layout problem. The handler also has to ignore clicks that land on inner links, and a call to action must stay styled text instead of a second link, which is Nomensa’s suggestion for this pattern.

Pick the stretched link by default. Restaumatic’s accessible card write-up covers the selection problem by raising the text above the overlay with z-index, which restores selecting and copying. CSS-Tricks names the trade-off: text you swap above the overlay stops acting as part of the link.

Pair the pattern with the :focus-within rule from the previous section so keyboard users see which card is active.

Accessibility Checks for CSS Cards

Before a card ships, check heading level, color contrast, link text and keyboard order.

  • Heading level follows the page outline, so cards under an h2 section use h3
  • “Read more” repeated on every card tells a screen reader user nothing, so link the title or add visually hidden context
  • Tab order should match reading order
  • Informative images get descriptive alt text, and decorative ones get alt="" so the title is not announced twice

WCAG 2.2 requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text, which W3C defines as 18 point or 14 point bold. Under success criterion 1.4.11, non-text parts such as a card border or focus ring need 3:1 against adjacent colors.

Test color contrast on every card variant, because a theme override can turn a passing card into a failing one. Text laid over an image needs a solid or semi-transparent overlay behind it, since the ratio changes with every photo.

WebAIM’s 2026 Million report found low contrast text on 83.9% of the home pages it tested, up from 79.1% in 2025 and the most common failure it recorded.

Reordering focusable elements with Flexbox order splits the visual sequence from the tab sequence. Reorder only non-focusable parts such as the image, and leave links and buttons in source order.

The broader web accessibility checklist covers forms, tables and media beyond cards.

CSS Cards FAQ

Should I use Bootstrap, Tailwind CSS or custom CSS cards?

Custom CSS fits a project with a few card types and its own design system. Bootstrap or Tailwind CSS fits a project that already loads the framework, since loading one only for cards adds code the project never uses.

Bootstrap ships a ready-made card component with predefined classes, while Tailwind CSS builds the same card from utility classes in the markup.

Does Material Design elevation apply to custom cards?

Only as a visual convention. Material Design uses shadow depth to show how far a card sits above the page, and you can copy that with layered box-shadow values.

Nothing enforces it, because elevation is a design guideline and not a CSS feature.

How do I build a flip or 3D card?

Put a front face and a back face inside one wrapper, set perspective on the card, and rotate the wrapper 180 degrees with rotateY. Add transform-style: preserve-3d to the wrapper and backface-visibility: hidden to both faces.

Hover does nothing on touch screens, so trigger the flip from :focus-within or a button as well. A dedicated CSS flip card guide covers the full markup.

How do I center a single card or a row of cards?

Center a single card with a max-width and margin-inline: auto. Center a row with justify-content: center on the flex or grid container.

In a grid, use fixed-width tracks such as repeat(auto-fill, 240px). Tracks sized with 1fr already fill the row, so there is nothing left to center.

What to Build and Test First in a CSS Card Component

Start with the markup and keyboard behavior before any visual layer. Semantics are the hardest part to retrofit across a finished set of cards, while shadows and radii change in one rule.

After that comes the grid layout with subgrid rows, and the container query for the stacked-to-horizontal switch goes last.

Test by resizing the grid cell instead of the window, then tab through every card variant. The cost of this order is slower visible progress, since stakeholders see a plain card for longer.

As of 15 October 2026, style queries on custom properties are recent enough to remain an enhancement with a fallback, not a dependency. Once the card passes these checks, it belongs in the shared component library of your design system.