Skip to content
April 19, 2025

Stacked Cards with Tailwind CSS

In the early 2010s, I sat in a meeting with a mobile app start-up who wanted to disrupt business cards. For some context, the Apple App Store launched in July 2008 and for years after the software landscape was littered with fly-by-night “disruptors.” Never before was it easier to get software into the pockets of everyday people. It spurred a lot of useful products and it also brought out the grifters. This start-up created an app that encouraged people to create and share custom cards that featured the person’s name and photo on the front and when tapped to flip, the person’s contact info and other “stats” were shown, like a baseball card.

This was the height of skeuomorphism. The Find My Friends app still had its stitched interface. One unforseen problem with skeuomorphic design patterns was that people who had never even considered building software before confused the metaphor for innovation. This card app start-up (I don’t even remember its name) failed before it even started. It wasn’t a business, it was just a user interface experiment.

But, skeuomorphism lives on! Cards are still a useful design pattern. They can be swiped away, flipped, unfolded, stacked. Notification toasts are one of the most popular examples of this pattern. Here’s an example of how to stack some notification cards using only Tailwind CSS.

Notifications

The first step is to build a basic notification card. Here’s an example of one that looks pretty convincing.

type Notification = {
  name: string
  photo: string
  action: 'follow' | 'like' | 'comment'
  at: string
}
 
export const NotificationCard1 = ({ name, photo, action, at }: Props) => (
  <figure className="rounded-lg bg-slate-100 shadow-sm">
    <div className="flex w-full flex-row items-center gap-3.5 p-3">
      <div className="relative size-12">
        <img src={photo} className="rounded-full object-cover" />
        {action === 'like' && (
          <div className="absolute -bottom-0.5 -right-1 rounded-full bg-sky-500 p-1">
            <ThumbsUpIcon className="pointer-events-none size-2.5 fill-current text-slate-50" />
          </div>
        )}
        {action === 'follow' && (
          <div className="absolute -bottom-0.5 -right-1 rounded-full bg-emerald-500 p-1">
            <FollowIcon className="pointer-events-none size-2.5 fill-current text-slate-50" />
          </div>
        )}
        {action === 'comment' && (
          <div className="absolute -bottom-0.5 -right-1 rounded-full bg-violet-500 p-1">
            <CommentIcon className="pointer-events-none size-2.5 fill-current text-slate-50" />
          </div>
        )}
      </div>
      <figcaption className="flex flex-col rounded-lg leading-5">
        <p className="text-sm text-slate-700">
          <strong>{name}</strong> {action === 'follow' && <>followed you</>}
          {action === 'like' && (
            <>
              liked <strong>your post</strong>
            </>
          )}
          {action === 'comment' && (
            <>
              commented on <strong>your post</strong>
            </>
          )}
        </p>
        <p className="text-sm font-light text-slate-500">{at}</p>
      </figcaption>
    </div>
  </figure>
)

I’m using dummy json to fill in five notifications. This expanding notifications component will assume that there will never be more than five notifications. Once you try to show more than that, you’re going to need to use refs to inspect the rendered DOM elements to get heights and use style props. More on that later. For now, here are five cards.

Chris Romero followed you

just now

Justin W. liked your post

15 minutes ago

Elaine Tomlin liked your post

3 hours ago

Robert Davis followed you

yesterday

Van commented on your post

2 days ago

Stacking

To stack the cards, wrap the NotificationCard components in a div with a flex-col. Set overflow-y-hidden and max-h-32 to hide the extra space left from the translations.

<div className="flex max-h-32 w-80 flex-col gap-2 overflow-y-hidden">
  {notifications.map((notification, index) => (
    <NotificationCard key={index} {...notification} />
  ))}
</div>

Add the following translate and scale classes to the NotificationCard’s figure. The translate-z-px class is needed to address some z-index issues in Safari. Replace the bg-slate-100 with bg-slate-400.

const NotificationCard = ({ name, photo, action, at }: Props) => (
  <figure className="translate-z-px rounded-lg bg-slate-400 shadow-sm first-of-type:z-20 first-of-type:scale-100 first-of-type:bg-slate-100 nth-of-type-[2]:z-10 nth-of-type-[2]:-translate-y-18 nth-of-type-[2]:scale-95 nth-of-type-[2]:bg-slate-300 nth-of-type-[3]:-translate-y-36 nth-of-type-[3]:scale-90 nth-of-type-[4]:-translate-y-58 nth-of-type-[4]:scale-85 nth-of-type-[5]:-translate-y-78 nth-of-type-[5]:scale-80">

Now your notifications will stack like the example below. The first three items are visible and the last two are translated up enough to be behind the first three items.

Chris Romero followed you
just now
Justin W. liked your post
15 minutes ago
Elaine Tomlin liked your post
3 hours ago
Robert Davis followed you
yesterday
Van commented on your post
2 days ago

State

Now for handling expanding and collapsing the notifications, add a checkbox input element. Use the peer class to share the checked state from the label with the NotificationCard components. Add has-checked:max-h-[1000px] to the containing div to make sure that the container expands enough to show all five notifications.

<div className="has-checked:max-h-[1000px] flex max-h-32 w-80 shrink flex-col gap-2 overflow-y-hidden">
  <label className="peer flex w-full flex-row items-center gap-1.5 text-xs">
    <input type="checkbox" />
    Expand/Collapse
  </label>
  {notifications.map((notification, index) => (
    <NotificationCard key={index} {...notification} />
  ))}
</div>

To the NotificationCard component, add not-first-of-type:peer-has-checked:bg-slate-100 to make all the notification backgrounds change to the default background color when the checkbox is checked. Then use not-first-of-type:peer-has-checked:translate-y-0 not-first-of-type:peer-has-checked:scale-100 to translate the notifications back to their natural location and scaled back to 100%. After that, the basic functionality is all there.

Chris Romero followed you
just now
Justin W. liked your post
15 minutes ago
Elaine Tomlin liked your post
3 hours ago
Robert Davis followed you
yesterday
Van commented on your post
2 days ago

Animations

Adding animations is the easiest part. To the main container div, add transition-[max-height] duration-300 ease-in-out to animate the max-height of the container. Then add transition duration-300 ease-in-out to the figure inside the NotificationCard component as well.

Chris Romero followed you
just now
Justin W. liked your post
15 minutes ago
Elaine Tomlin liked your post
3 hours ago
Robert Davis followed you
yesterday
Van commented on your post
2 days ago

Final Result

Below is the completed NotificationStack component.

Chris Romero followed you
just now
Justin W. liked your post
15 minutes ago
Elaine Tomlin liked your post
3 hours ago
Robert Davis followed you
yesterday
Van commented on your post
2 days ago

The final result can be forked on CodePen. It has a little extra work done to style the expand/collapse buttons and adds focus and hover states.

Here’s all the code for reference:

import type { SVGProps } from 'react'
 
type Notification = {
  name: string
  photo: string
  action: 'follow' | 'like' | 'comment'
  at: string
}
 
const notifications: Array<Notification> = [
  {
    "photo": "https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2.2&w=160&h=160&q=80",
    "at": "just now",
    "name": "Christina J. Romero",
    "action": "follow"
  },
  {
    "photo": "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2.25&w=256&h=256&q=80",
    "at": "15 minutes ago",
    "name": "Justin Watson",
    "action": "like"
  },
  {
    "photo": "https://images.unsplash.com/photo-1550525811-e5869dd03032?ixlib=rb-1.2.1&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80",
    "at": "3 hours ago",
    "name": "Elaine Tomlin",
    "action": "like"
  },
  {
    "photo": "https://images.unsplash.com/photo-1491528323818-fdd1faba62cc?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80",
    "at": "yesterday",
    "name": "Robert Davis",
    "action": "follow"
  },
  {
    "photo": "https://images.unsplash.com/photo-1519345182560-3f2917c472ef?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80",
    "at": "2 days ago",
    "name": "D. Van",
    "action": "comment"
  }
]
 
const ThumbsUpIcon = (props: SVGProps<SVGSVGElement>) => (<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 -960 960 960" {...props}><path d="M720-120H320v-520l280-280 50 50q7 7 11.5 19t4.5 23v14l-44 174h218q32 0 56 24t24 56v80q0 7-1.5 15t-4.5 15L794-168q-9 20-30 34t-44 14ZM240-640v520H80v-520h160Z"/></svg>)
 
const FollowIcon = (props: SVGProps<SVGSVGElement>) => (<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" {...props}><path d="M720-400v-120H600v-80h120v-120h80v120h120v80H800v120h-80Zm-360-80q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47ZM40-160v-112q0-34 17.5-62.5T104-378q62-31 126-46.5T360-440q66 0 130 15.5T616-378q29 15 46.5 43.5T680-272v112H40Z"/></svg>)
 
const CommentIcon = (props: SVGProps<SVGSVGElement>) => (<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" {...props}><path d="M80-80v-720q0-33 23.5-56.5T160-880h640q33 0 56.5 23.5T880-800v480q0 33-23.5 56.5T800-240H240L80-80Z"/></svg>)
 
const NotificationCard = ({ name, photo, action, at }: Notification) => (
  <figure className="translate-z-px rounded-lg bg-slate-400 shadow-sm transition duration-300 ease-in-out not-first-of-type:peer-has-checked:translate-y-0 not-first-of-type:peer-has-checked:scale-100 not-first-of-type:peer-has-checked:bg-slate-100 first-of-type:z-20 first-of-type:scale-100 first-of-type:bg-slate-100 nth-of-type-[2]:z-10 nth-of-type-[2]:-translate-y-18 nth-of-type-[2]:scale-95 nth-of-type-[2]:bg-slate-300 nth-of-type-[3]:-translate-y-36 nth-of-type-[3]:scale-90 nth-of-type-[4]:-translate-y-58 nth-of-type-[4]:scale-85 nth-of-type-[5]:-translate-y-78 nth-of-type-[5]:scale-80">
    <div className="flex flex-row items-center gap-3.5 p-3">
      <div className="relative size-12">
        <img src={photo} className="rounded-full object-cover" />
        {action === 'like' && (
          <div className="absolute -right-1 -bottom-0.5 rounded-full bg-sky-500 p-1">
            <ThumbsUpIcon className="pointer-events-none size-2.5 fill-current text-slate-50" />
          </div>
        )}
        {action === 'follow' && (
          <div className="absolute -right-1 -bottom-0.5 rounded-full bg-emerald-500 p-1">
            <FollowIcon className="pointer-events-none size-2.5 fill-current text-slate-50" />
          </div>
        )}
        {action === 'comment' && (
          <div className="absolute -right-1 -bottom-0.5 rounded-full bg-violet-500 p-1">
            <CommentIcon className="pointer-events-none size-2.5 fill-current text-slate-50" />
          </div>
        )}
      </div>
      <figcaption className="flex flex-col rounded-lg leading-5">
        <div className="text-sm text-slate-700">
          <strong>{name}</strong> {action === 'follow' && <>followed you</>}
          {action === 'like' && (
            <>
              liked <strong>your post</strong>
            </>
          )}
          {action === 'comment' && (
            <>
              commented on <strong>your post</strong>
            </>
          )}
        </div>
        <div className="text-sm font-light text-slate-500">{at}</div>
      </figcaption>
    </div>
  </figure>
)
 
const NotificationStack = () => (
  <section className="relative flex max-h-42 w-80 shrink flex-col gap-2 overflow-hidden rounded-2xl border border-slate-700 bg-slate-900 p-4 text-left transition-[max-height] duration-300 ease-in-out select-none has-checked:max-h-[1000px]">
    <label
      htmlFor="toggle-stack"
      className="peer flex cursor-pointer items-center pb-2">
      <input
        type="checkbox"
        value=""
        className="peer absolute opacity-0"
        id="toggle-stack"
      />
      <h1 className="flex-1 text-lg font-semibold text-slate-300">
        Notifications
      </h1>
      <div className="rounded-full outline-offset-2 peer-checked:hidden peer-focus-visible:outline-1 peer-focus-visible:outline-slate-400">
        <div
          className="cursor-pointer rounded-full bg-slate-800 px-3 py-1 text-xs text-slate-400 transition duration-300 ease-in-out outline-none hover:bg-slate-700"
          aria-hidden="true">
          Expand
        </div>
      </div>
      <div className="rounded-full outline-offset-2 peer-focus-visible:outline-1 peer-focus-visible:outline-slate-400 peer-[:not(:checked)]:hidden">
        <div
          className="cursor-pointer rounded-full bg-slate-800 px-3 py-1 text-xs text-slate-400 transition duration-300 ease-in-out outline-none hover:bg-slate-700"
          aria-hidden="true">
          Collapse
        </div>
      </div>
    </label>
 
    {notifications.map((notification, index) => (
      <NotificationCard
        key={`notification-card-${index}`}
        {...(notification as Notification)}
      />
    ))}
 
    <div className="flex -translate-y-88 translate-z-px flex-col items-center pt-2 opacity-0 transition duration-100 ease-in-out peer-has-checked:translate-y-0 peer-has-checked:opacity-100">
      <button
        type="button"
        className="min-w-1/2 cursor-pointer rounded-full bg-slate-800 py-1 text-xs text-slate-400 hover:bg-slate-700 focus-visible:outline-1 focus-visible:outline-slate-400">
        See all activity
      </button>
    </div>
  </section>
)
 

If you want to expand more cards, an unknown number of cards or variable height cards, then you’re going to need to add a ref to detect each card’s height and relative distance from the top of its container and then use style props like style={{ 'transform': 'translateY(-120px)' }}. Usually, like in the case of notification toasts, you want to keep these cards at a consistent height to keep to a manageable predictable amount of screen real-estate. But, other implementations may have different requirements.