---
title: "Create a Dynamic Card Hover Effect in React"
slug: "self-adjusting-card-hover-effect-react"
published: "2025-11-06"
updated: "2025-11-12"
categories:
  - "React"
tags:
  - "React card hover effect"
  - "CSS transitions"
  - "dynamic content in React"
  - "max-height transitions"
  - "self-adjusting card"
  - "React hover effect tutorial"
  - "CSS animation"
  - "Tailwind CSS"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "react@18"
  - "typescript@5"
  - "tailwindcss@3"
status: "stable"
llm-purpose: "Discover how to implement a dynamic card hover effect in React using CSS's max-height transitions for a smooth user experience."
llm-prereqs:
  - "Access to React"
  - "Access to TypeScript"
  - "Access to Tailwind CSS"
llm-outputs:
  - "Completed outcome: Discover how to implement a dynamic card hover effect in React using CSS's max-height transitions for a smooth user experience."
---

**Summary Triples**
- (Technique, uses, CSS max-height transitions instead of transform-based translate)
- (Benefit, avoids, hardcoded pixel heights and content clipping for variable-length descriptions)
- (Behavior, expandsTo, the element's natural content height (up to the defined max-height))
- (Implementation detail, requires, overflow: hidden and transition on max-height)
- (Tailwind usage, mapsTo, classes like max-h-0, max-h-[24rem] (or other arbitrary values), overflow-hidden, transition-[max-height], group-hover: and focus-within:)
- (Accessibility, shouldInclude, keyboard focus states (focus-within / tabindex) so non-mouse users can trigger the reveal)
- (Performance, considers, setting a reasonable max-height cap (e.g., 24rem) to limit layout work during transitions)
- (JavaScript, isNotRequired, for height calculation—the approach is CSS-only for the reveal animation)
- (Edge case, handles, short and long descriptions by expanding just enough or up to the max-height)

### {GOAL}
Discover how to implement a dynamic card hover effect in React using CSS's max-height transitions for a smooth user experience.

### {PREREQS}
- Access to React
- Access to TypeScript
- Access to Tailwind CSS

### {STEPS}
1. Understanding the Challenge
2. Implementing max-height Transition
3. Building the Card Component
4. Adding Content Layer
5. Testing the Component

<!-- llm:goal="Discover how to implement a dynamic card hover effect in React using CSS's max-height transitions for a smooth user experience." -->
<!-- llm:prereq="Access to React" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:prereq="Access to Tailwind CSS" -->
<!-- llm:output="Completed outcome: Discover how to implement a dynamic card hover effect in React using CSS's max-height transitions for a smooth user experience." -->

# Create a Dynamic Card Hover Effect in React
> Discover how to implement a dynamic card hover effect in React using CSS's max-height transitions for a smooth user experience.
Matija Žiberna · 2025-11-06

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:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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

## LLM Response Snippet
```json
{
  "goal": "Discover how to implement a dynamic card hover effect in React using CSS's max-height transitions for a smooth user experience.",
  "responses": [
    {
      "question": "What does the article \"Create a Dynamic Card Hover Effect in React\" cover?",
      "answer": "Discover how to implement a dynamic card hover effect in React using CSS's max-height transitions for a smooth user experience."
    }
  ]
}
```