EmpireUI
Get Pro
← Blog7 min read#expandable-card#react-components#hover-effects

Expandable Reveal Cards: Hover-to-Expand UI in React

Build hover-to-expand reveal cards in React with Tailwind CSS. Smooth transitions, accessible markup, and reusable component patterns for modern UIs.

Code editor showing React component code with colorful syntax highlighting on a dark background

What Are Expandable Reveal Cards and Why They Work

Honestly, the hover-to-expand card is one of the few UI patterns that actually earns its interaction cost. Most hover states are purely decorative. This one reveals content that genuinely lives behind a surface — which means the interaction has meaning.

The core idea is simple: you show a compressed card at rest, then expand it (or reveal a hidden layer) on hover or focus. The motion communicates hierarchy. The collapsed state says "there's more," and the expanded state delivers it. No extra clicks, no page transitions.

Where you see this pattern most: portfolio grids, SaaS feature grids, product card galleries, team member bios. Anywhere you have a dense collection and you want to avoid paginating or stacking walls of text at page load.

It pairs naturally with other Empire UI motion components. If you've already looked at cards stack layouts in React, you'll recognize the same stacking logic at play — just unfolding on a single axis rather than a z-stack.

Two Core Reveal Approaches: Height Expansion vs. Layer Flip

There are really two distinct patterns here, and conflating them causes bugs. The first is height expansion — the card grows vertically on hover, pushing surrounding content or overflowing with overflow: hidden clipped to its container. The second is a layer flip or overlay reveal — the card stays the same size, but a translucent or opaque layer slides or fades up from the bottom.

Height expansion is more layout-invasive. If your grid uses CSS Grid with auto rows, expanding one card shifts the entire row unless you plan for it. The cleanest fix is fixed-height rows (grid-auto-rows: 280px) with overflow: hidden on each cell, then animate only the inner content's max-height or transform: translateY.

Layer overlay reveal is layout-safe. The card keeps its footprint. An absolutely-positioned overlay child starts at translateY(100%) and transitions to translateY(0) on hover. Nothing shifts. This is usually the right default for production grids — it's predictable at every breakpoint.

Which should you choose? If the revealed content is short (a name, a label, a two-line description), go overlay. If users need to read a paragraph or see a list of features, go height expansion — but keep the expanded height bounded. Don't let it grow infinitely.

Building the Overlay Reveal Card in React and Tailwind

Here's the component. It uses Tailwind v4.0.2 with the group hover utility, no JavaScript state required for the basic version. The overlay slides up from the bottom with a translate-y-full to translate-y-0 transition over 300ms.

import { cn } from '@/lib/utils'

interface RevealCardProps {
  image: string
  title: string
  description: string
  className?: string
}

export function RevealCard({ image, title, description, className }: RevealCardProps) {
  return (
    <div
      className={cn(
        'group relative overflow-hidden rounded-2xl cursor-pointer',
        'h-[320px] w-full',
        className
      )}
    >
      {/* Base image layer */}
      <img
        src={image}
        alt={title}
        className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
      />

      {/* Overlay reveal layer */}
      <div
        className={cn(
          'absolute inset-x-0 bottom-0 h-1/2',
          'translate-y-full transition-transform duration-300 ease-out',
          'group-hover:translate-y-0',
          'bg-gradient-to-t from-black/80 via-black/60 to-transparent',
          'flex flex-col justify-end px-5 pb-5 gap-2'
        )}
      >
        <h3 className="text-white text-lg font-semibold leading-tight">{title}</h3>
        <p className="text-white/75 text-sm line-clamp-3">{description}</p>
      </div>

      {/* Always-visible title bar at bottom */}
      <div className="absolute inset-x-0 bottom-0 bg-black/40 px-5 py-3 transition-opacity duration-300 group-hover:opacity-0">
        <span className="text-white text-sm font-medium">{title}</span>
      </div>
    </div>
  )
}

A few things worth noting. The group class on the wrapper lets all child elements respond to hover without threading a hovered state variable through props. The image gets a subtle scale-105 on hover — 5% is enough to feel alive without being distracting. The overlay uses h-1/2, not h-full, so it doesn't cover the entire card on expand.

Height-Expanding Card with React useState and CSS max-height

Sometimes you need JavaScript state — specifically when you want keyboard accessibility, touch support, or the expand to be toggleable (click to open, click to close). The CSS-only group-hover approach won't cut it for keyboard users navigating with Tab.

import { useState } from 'react'
import { cn } from '@/lib/utils'

interface ExpandCardProps {
  title: string
  summary: string
  details: string
  accentColor?: string
}

export function ExpandCard({
  title,
  summary,
  details,
  accentColor = 'rgba(255,255,255,0.15)'
}: ExpandCardProps) {
  const [expanded, setExpanded] = useState(false)

  return (
    <div
      role="button"
      tabIndex={0}
      aria-expanded={expanded}
      onMouseEnter={() => setExpanded(true)}
      onMouseLeave={() => setExpanded(false)}
      onFocus={() => setExpanded(true)}
      onBlur={() => setExpanded(false)}
      onKeyDown={(e) => e.key === 'Enter' && setExpanded((v) => !v)}
      className={cn(
        'relative rounded-2xl border border-white/10 p-6',
        'bg-[rgba(15,15,20,0.85)] backdrop-blur-md',
        'cursor-pointer select-none outline-none',
        'focus-visible:ring-2 focus-visible:ring-white/40'
      )}
      style={{ '--accent': accentColor } as React.CSSProperties}
    >
      <div
        className="absolute inset-0 rounded-2xl opacity-0 transition-opacity duration-300"
        style={{ background: `radial-gradient(circle at 50% 0%, ${accentColor}, transparent 70%)` }}
        aria-hidden="true"
        data-expanded={expanded}
        // Use a className swap trick or inline style for the opacity
      />

      <h3 className="text-white font-semibold text-base mb-1">{title}</h3>
      <p className="text-white/60 text-sm">{summary}</p>

      <div
        className="overflow-hidden transition-all duration-350 ease-in-out"
        style={{ maxHeight: expanded ? '200px' : '0px' }}
      >
        <div className="pt-4 border-t border-white/10 mt-4">
          <p className="text-white/75 text-sm leading-relaxed">{details}</p>
        </div>
      </div>
    </div>
  )
}

The max-height trick is the standard CSS animation workaround for height: auto. Set maxHeight to a value you know is large enough for your content — here 200px. Setting it too low clips content; setting it too large creates a sluggish, delayed close animation. 200px for short paragraphs, 400px for longer detail sections.

Notice the aria-expanded attribute on the root element. Screen readers announce the state change automatically. The focus-visible:ring-2 ensures keyboard users see the focus indicator without it appearing on mouse click (modern browsers handle this distinction natively).

Grid Layout: Keeping the Reveal Stable Across Breakpoints

The most common complaint with reveal cards in a grid: one card expands and the whole layout jumps. This happens when you're using implicit row heights. The fix is explicit row sizing.

.reveal-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  grid-auto-rows: 320px; /* fixed row height */
  gap: 16px; /* 16px = 4 in Tailwind gap-4 */
  overflow: hidden;
}

.reveal-grid > * {
  overflow: hidden; /* clip inner content, not card footprint */
}

With grid-auto-rows: 320px and overflow: hidden on each cell, a height-expanding card won't shift rows — it just clips at the cell boundary. You might think that ruins the expansion, but pair it with the overlay approach and you get the best of both: content slides up within the fixed cell, nothing shifts.

On mobile, the hover trigger doesn't fire. You'll need a fallback — either auto-expand all cards at sm: breakpoints, or switch to click-toggle behavior with onTouchStart. A simple media query check via a useMediaQuery hook works. Don't assume hover is available just because the screen is wide; touch laptops exist. The animated tabs pattern handles a similar touch-vs-pointer distinction worth borrowing from.

Accessibility: Making Hover Interactions Work for Everyone

Here's a pattern that trips up a lot of developers: CSS-only hover interactions that are invisible to keyboard and screen reader users. group-hover is great for visual polish, but it does nothing when someone Tabs to your card.

The minimum viable accessible reveal card needs three things: tabIndex={0} on the wrapper (or use a native <button>), aria-expanded toggled on focus and blur, and the hidden content reachable in DOM order. Don't use visibility: hidden to hide the overlay — that removes it from the accessibility tree entirely. Use opacity: 0 plus pointer-events: none instead, or the max-height: 0 trick which collapses space without removing DOM nodes.

Is there a performance cost to the useState approach versus pure CSS? For a grid of 20 cards, no. React's reconciler is fast enough that individual hover state updates on simple components don't cause visible jank. You'd need hundreds of cards with heavy children before you'd notice. Profile first before reaching for useMemo or virtualization.

One more thing: respect prefers-reduced-motion. Wrap your transition durations in a media query or use Tailwind's motion-safe: prefix variant. People with vestibular disorders find animated expansions genuinely uncomfortable at full speed.

Styling Variants: Glassmorphism, Dark Mode, and Accent Colors

The overlay reveal card is a natural fit for glassmorphism styling — the frosted glass surface and the reveal overlay work together visually. If you want to understand the technique before applying it, what is glassmorphism breaks it down from first principles.

For dark mode, the key values to parameterize are the overlay background and the text contrast. A starting point: rgba(10, 10, 15, 0.85) for the overlay in dark, rgba(245, 245, 250, 0.9) in light. The backdrop-filter: blur(12px) stays the same either way. Wire these to your CSS custom properties and dark: Tailwind variants.

Accent color injection via CSS custom properties is the cleanest API for making reveal cards themeable. Pass --card-accent as an inline style on the wrapper and reference it in your CSS with background: radial-gradient(circle, var(--card-accent, rgba(255,255,255,0.15)), transparent). Each card in a grid can have its own accent without a prop explosion. This is how Empire UI's 40 visual styles stay composable — each style injects its own set of CSS variables rather than duplicating component logic.

Composing with Empire UI: Connecting Reveal Cards to Other Components

Reveal cards rarely live in isolation. In a real product, they're nested inside a scrolling marquee, a responsive bento grid, or a tabbed section filter. Each of those wrapping contexts has layout constraints that interact with the card's expand behavior.

Inside a bento grid layout, the card footprint must stay fixed — so use the overlay reveal variant, not height expansion. The bento grid defines each cell's size; the card should respect it. Inside a marquee, disable expand on hover entirely (the motion conflicts with the scroll animation) and instead open a modal or drawer on click.

The RevealCard component above accepts a className prop for exactly this reason. The parent grid or layout wrapper controls the sizing; the card controls only the reveal behavior. That's the right separation. Don't bake dimensions into the card component itself — callers know their context better than the component does.

One last thing worth doing: export your reveal card with a data-variant attribute that reflects which style is active. It makes E2E testing much simpler — you can select [data-variant="overlay"] or [data-variant="expand"] and assert on the correct behavior without relying on class-name snapshots that break on every Tailwind upgrade.

FAQ

Can I animate the reveal with CSS transitions alone, without JavaScript state?

Yes, for hover-only interactions. Tailwind's group and group-hover: utilities handle the entire reveal with zero JS. The limitation is keyboard and touch accessibility — you'll need useState and explicit focus/blur handlers if those matter for your use case.

Why does max-height animation feel slow on the close transition?

Because CSS animates from max-height: 200px all the way to max-height: 0px, even if the actual content is only 60px tall. The browser calculates the full range. Fix it by setting max-height closer to the actual content height, or use a JS-driven animation library like Motion that animates the real height property directly.

How do I stop the card grid from shifting when one card expands?

Set a fixed grid-auto-rows value in your CSS (e.g., 320px) and overflow: hidden on each grid cell. This clips the expanding card at its cell boundary. Pair with the overlay reveal variant so content slides up within the fixed space rather than pushing the cell boundary outward.

What's the right aria attribute for an expand/collapse card?

Use aria-expanded on the interactive element (the card wrapper or a button inside it). Set it to true when expanded, false when collapsed. Screen readers announce the state change automatically. Also add aria-controls pointing to the id of the revealed content region for better screen reader context.

Does the scale-105 on hover cause layout shift?

No — CSS transform: scale() doesn't affect layout. It scales visually without changing the element's footprint in the document flow. Just make sure overflow: hidden is set on the card wrapper, otherwise the scaled image bleeds outside the card's rounded corners.

How do I handle touch devices where hover doesn't fire?

Use onTouchStart to trigger the expanded state and onTouchEnd or a click outside handler to collapse it. Alternatively, auto-expand all cards on screen widths below 768px using a useMediaQuery hook, which sidesteps the interaction problem entirely on mobile.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Chip & Tag Input: Building Gmail-Style Tag Components in ReactEmoji Picker in React: Searchable, Categorised, LightweightDark Card Hover Animations: Transform, Glow, Scale EffectsGlow Button in React: CSS Box Shadow Animation on Hover