Create a Dynamic Card Hover Effect in React

Master self-adjusting card hover effects with CSS transitions in React. No hardcoding, just smooth animations.

·Matija Žiberna·
Create a Dynamic Card Hover Effect in React

⚛️ Advanced React Development Guides

Comprehensive React guides covering hooks, performance optimization, and React 19 features. Includes code examples and prompts to boost your workflow.

No spam. Unsubscribe anytime.

If you're building card components in React and need a hover effect where the title slides up to reveal a description and button—without hardcoding pixel values or dealing with cut-off content—this guide shows you exactly how to implement it. By the end, you'll understand how to use CSS's max-height transitions to create hover effects that automatically adjust to any content length.

The Challenge with Variable Content

When building card hover effects, the common approach is to use CSS transforms to slide content up or down. The problem? Transforms require fixed pixel values like translate-y-20 or translate-y-32, which means you're guessing how much space your content needs. Short descriptions leave too much empty space, while longer ones get cut off.

What we need is a solution that adapts to the actual content height automatically.

Understanding the Solution: max-height Over transform

The key insight is to use max-height transitions instead of positional transforms. When you transition from max-height: 0 to a generous maximum like max-height: 24rem, CSS automatically expands the element to its natural content height (up to that maximum). This means:

  • Short descriptions expand just enough to fit their content
  • Long descriptions expand fully without being cut off
  • The title naturally moves up by exactly the right amount
  • No JavaScript calculations required

This works because max-height keeps the content in the document flow, while transforms take elements out of their natural position.

Building the Card Component

Let's start with the basic card structure. We'll use React with TypeScript and Tailwind CSS for styling:

// File: src/components/IndustryCard.tsx
interface IndustryCardProps {
  industry: Industry;
  overlayOpacity: number;
}

function IndustryCard({ industry, overlayOpacity }: IndustryCardProps) {
  const imageUrl =
    typeof industry.image === 'object' ? industry.image?.url ?? '' : '';
  const imageAlt =
    typeof industry.image === 'object'
      ? industry.image?.alt ?? industry.title
      : industry.title;
  const imageWidth =
    typeof industry.image === 'object'
      ? industry.image?.width ?? 457
      : 457;
  const imageHeight =
    typeof industry.image === 'object'
      ? industry.image?.height ?? 630
      : 630;

  const descriptionText = industry.shortDescription || '';

  return (
    <div className="relative overflow-hidden rounded-lg aspect-[457/630] group cursor-pointer">
      {/* Background Image */}
      {imageUrl && (
        <Image
          src={imageUrl}
          alt={imageAlt || industry.title}
          width={imageWidth}
          height={imageHeight}
          className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
          priority={false}
        />
      )}

      {/* Overlay - increases opacity on hover */}
      <div
        className="absolute inset-0 bg-black transition-opacity duration-300 group-hover:opacity-60"
        style={{
          opacity: overlayOpacity / 100,
        }}
      />

      {/* Content will go here */}
    </div>
  );
}

The foundation uses a group class on the parent container, which allows child elements to respond to hover states using Tailwind's group-hover: modifier. The image scales slightly on hover, and the overlay darkens to improve text readability.

Implementing the Content Layer

Now we'll add the content layer with the self-adjusting hover behavior:

// File: src/components/IndustryCard.tsx (content section)
{/* Content Container - flexbox, title at bottom */}
<div className="absolute inset-0 flex flex-col justify-end p-8">
  {/* Scrollable wrapper for overflow content */}
  <div className="relative max-h-full overflow-hidden">
    {/* Inner wrapper that moves both title and description together */}
    <div className="transition-transform duration-500 ease-in-out">
      {/* Title - always visible at bottom */}
      <h3 className="text-white text-3xl font-bold leading-tight">
        {industry.title}
      </h3>

      {/* Description & CTA - hidden below, revealed on hover */}
      {descriptionText && (
        <div className="max-h-0 opacity-0 overflow-hidden transition-all duration-500 ease-in-out group-hover:max-h-96 group-hover:opacity-100">
          {/* Description */}
          <p className="text-white text-sm leading-snug mt-4">
            {descriptionText}
          </p>

          {/* CTA Button */}
          <Button
            variant="link"
            size="sm"
            className="text-white hover:text-primary mt-3 p-0"
          >
            Learn More
            <ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
          </Button>
        </div>
      )}
    </div>
  </div>
</div>

This structure has three key layers working together. The outer container uses flex flex-col justify-end to anchor content at the bottom. The middle wrapper handles overflow, and the inner wrapper contains both the title and the expandable description.

How the max-height Transition Works

The critical part is the description container with max-h-0 that transitions to max-h-96 on hover. Here's what happens in each state:

Default state: The description has max-h-0 and opacity-0, making it completely collapsed and invisible. It takes up zero space in the layout, so the title sits naturally at the bottom of the card.

Hover state: The group-hover:max-h-96 allows the container to expand up to 24rem (384px), and group-hover:opacity-100 fades it in. The container expands to fit its actual content height, which pushes the title up by exactly that amount.

The duration-500 ease-in-out timing gives the animation a smooth, deliberate feel—fast enough to be responsive but slow enough to feel polished rather than jarring.

Why This Approach Works

Unlike transform-based solutions that move elements out of their natural position, this approach keeps everything in the document flow. When the description's max-height expands, it physically occupies space in the layout, which naturally pushes the title upward. There's no manual calculation of how far to move things—CSS handles the positioning automatically based on actual content dimensions.

The max-h-96 upper limit is generous enough to accommodate most description lengths. If you have exceptionally long content, you can increase this value, though 384px is typically more than sufficient for card descriptions. The key is that the actual expansion stops at the content's natural height, not at the maximum you specify.

Complete Working Example

Here's the full component with all pieces together:

// File: src/components/IndustryCard.tsx
import Image from 'next/image';
import { Button } from '@/components/ui/button';
import { ArrowRight } from 'lucide-react';

interface Industry {
  title: string;
  shortDescription?: string;
  image?: {
    url: string;
    alt?: string;
    width?: number;
    height?: number;
  };
}

interface IndustryCardProps {
  industry: Industry;
  overlayOpacity: number;
}

function IndustryCard({ industry, overlayOpacity }: IndustryCardProps) {
  const imageUrl =
    typeof industry.image === 'object' ? industry.image?.url ?? '' : '';
  const imageAlt =
    typeof industry.image === 'object'
      ? industry.image?.alt ?? industry.title
      : industry.title;
  const imageWidth =
    typeof industry.image === 'object'
      ? industry.image?.width ?? 457
      : 457;
  const imageHeight =
    typeof industry.image === 'object'
      ? industry.image?.height ?? 630
      : 630;

  const descriptionText = industry.shortDescription || '';

  return (
    <div className="relative overflow-hidden rounded-lg aspect-[457/630] group cursor-pointer">
      {/* Background Image */}
      {imageUrl && (
        <Image
          src={imageUrl}
          alt={imageAlt || industry.title}
          width={imageWidth}
          height={imageHeight}
          className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
          priority={false}
        />
      )}

      {/* Overlay */}
      <div
        className="absolute inset-0 bg-black transition-opacity duration-300 group-hover:opacity-60"
        style={{
          opacity: overlayOpacity / 100,
        }}
      />

      {/* Content Container */}
      <div className="absolute inset-0 flex flex-col justify-end p-8">
        <div className="relative max-h-full overflow-hidden">
          <div className="transition-transform duration-500 ease-in-out">
            <h3 className="text-white text-3xl font-bold leading-tight">
              {industry.title}
            </h3>

            {descriptionText && (
              <div className="max-h-0 opacity-0 overflow-hidden transition-all duration-500 ease-in-out group-hover:max-h-96 group-hover:opacity-100">
                <p className="text-white text-sm leading-snug mt-4">
                  {descriptionText}
                </p>

                <Button
                  variant="link"
                  size="sm"
                  className="text-white hover:text-primary mt-3 p-0"
                >
                  Learn More
                  <ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
                </Button>
              </div>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}

export default IndustryCard;

You can adjust the animation timing by changing the duration-500 value to be faster or slower depending on your design needs. The ease-in-out timing function provides the smoothest visual result for this type of expansion animation.

Conclusion

By using max-height transitions instead of positional transforms, you get a hover effect that automatically adapts to any content length without manual calculations. The title sits naturally at the bottom until hover, then slides up by exactly the right amount as the description expands into view. This CSS-native approach works with the browser's layout engine rather than fighting against it, resulting in predictable, maintainable code that handles edge cases gracefully.

Let me know in the comments if you have questions, and subscribe for more practical development guides.

Thanks, Matija

9

Comments

Leave a Comment

Your email will not be published

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.