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.
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.
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.
Final Result
Below is the completed NotificationStack
component.
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.