EmpireUI
Get Pro
← Blog8 min read#gradient#button#react

Gradient Button in React + Tailwind: Hover, Focus and Active States

Build a gradient button in React and Tailwind with correct hover, focus ring, and active state — plus the animation tricks most tutorials skip entirely.

Colorful gradient purple and blue abstract light texture background

Why Most Gradient Button Tutorials Are Wrong

You've seen the pattern a thousand times. Someone posts a gradient button snippet on Twitter, it gets 2,000 likes, and the code is bg-gradient-to-r from-purple-500 to-pink-500 hover:from-pink-500 hover:to-purple-500. Done. Ship it. The problem? Tailwind can't animate gradient color stops — at least not without a config hack. That direction-swap trick just flickers. There's no transition.

Honestly, this is one of those things that feels like it should just work but doesn't. The transition utility in Tailwind applies to background-color, not background-image, and CSS gradients are specified as background-image. So transition-all won't save you here. You need to think a bit differently.

This article covers three real approaches — the background-size trick, the pseudo-element overlay, and the CSS custom property approach introduced cleanly in Tailwind v3.3. You'll also get the focus ring and active scale state right, because those two are what separate a polished button from a prototype one.

Quick aside: if you want pre-built gradient components you can drop in immediately, check out the gradient generator on Empire UI — it spits out ready-to-copy Tailwind classes.

The background-size Animation Trick

This is the oldest trick in the book and it still works in 2026. The idea: set the gradient at 200% width, position it at the left half by default, then on hover shift it to the right. Because background-position is animatable, you get a smooth slide.

Here's the base CSS for this — you'll drop it into your globals or a module: ``css .btn-gradient { background: linear-gradient(90deg, #7c3aed, #ec4899, #7c3aed); background-size: 200% auto; background-position: left center; transition: background-position 0.4s ease; } .btn-gradient:hover { background-position: right center; } ``

In Tailwind, you can't express background-size: 200% or background-position transitions natively in utility classes without extending your config. So either use this as a one-off class in your CSS layer, or extend Tailwind's backgroundSize and backgroundPosition keys. Neither is a crime. Worth noting: the triple-color trick (#7c3aed → #ec4899 → #7c3aed) is what makes the loop look seamless — the start and end are the same hue.

Your React component looks like this: ``tsx export function GradientButton({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) { return ( <button className="btn-gradient px-6 py-3 rounded-xl text-white font-semibold text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-purple-400 focus-visible:ring-offset-2 active:scale-95 transition-transform" {...props} > {children} </button> ); } ``

Notice transition-transform is separate from the gradient transition — you can stack transforms and background animations independently. That active:scale-95 gives 5px of physical press feedback that users feel even if they can't articulate why the button feels good.

The Pseudo-Element Overlay Approach (More Control)

If you want to transition between two entirely different gradients — say, a calm blue-to-teal on default and a fiery orange-to-red on hover — the background-size trick won't get you there. The pseudo-element approach will. The idea is simple: layer a :before pseudo-element with the hover gradient on top, set opacity: 0 by default, then fade it in on hover.

.btn-gradient-overlay {
  position: relative;
  background: linear-gradient(135deg, #3b82f6, #06b6d4);
  border-radius: 12px;
  overflow: hidden;
}

.btn-gradient-overlay::before {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(135deg, #f97316, #ef4444);
  opacity: 0;
  transition: opacity 0.35s ease;
  border-radius: inherit;
}

.btn-gradient-overlay:hover::before {
  opacity: 1;
}

.btn-gradient-overlay span {
  position: relative;
  z-index: 1;
}

You need that span wrapper with z-index: 1 because the pseudo-element sits on top of the button's text otherwise. Annoying but unavoidable. In React, wrap your button children in a <span> inside the component or do it inline — either works fine.

In practice, this technique handles the widest variety of gradient combinations. It also degrades cleanly: if someone has reduced-motion preferences set, you drop the opacity transition to transition: none via @media (prefers-reduced-motion: reduce). Good accessibility doesn't need to cost much.

One more thing — overflow: hidden on the button is non-negotiable here. Without it, the pseudo-element bleeds outside the border-radius at the corners on Chromium-based browsers, and it looks terrible at 1px scale.

Focus Ring and Active State: Don't Skip These

You've seen what happens when designers skip the focus ring — keyboard users rage-quit the site. Tailwind's focus-visible:ring-* utilities are the right tool. They only fire on keyboard navigation, not on mouse clicks, which is exactly what WCAG 2.1 expects. The ring should be 2px wide with a 2px offset minimum — at 1px it's invisible against most backgrounds.

<button
  className="
    bg-gradient-to-br from-violet-600 to-fuchsia-600
    px-8 py-3 rounded-2xl text-white font-semibold
    hover:brightness-110
    focus-visible:outline-none
    focus-visible:ring-2
    focus-visible:ring-fuchsia-400
    focus-visible:ring-offset-2
    focus-visible:ring-offset-gray-900
    active:scale-95
    transition-all duration-150
  "
>
  Get started
</button>

The ring-offset-gray-900 here is a detail that trips people up. If your page background is dark, the default white offset ring looks like a glitch. Match the offset color to your page background — it creates the illusion of a gap between the ring and the button edge.

For active state, active:scale-95 is the standard. Some teams prefer active:brightness-90 instead, especially when the button text is light and a scale transform on a small mobile button feels off. Look, both work — pick the one that fits your design system and stick to it. Consistency beats perfection.

Worth noting: transition-all duration-150 is faster than the default duration-300 that Tailwind sets. 150ms is right at the edge of perceptible latency for button press feedback. Go slower and the button feels sluggish; go faster and the animation doesn't register at all.

Animating the Gradient Itself with @keyframes

If you want a perpetually moving gradient — the kind you see on premium landing pages — you need @keyframes. This isn't Tailwind-native, but it's about 8 lines of CSS. The technique rotates the gradient's background-position or hue-rotate over time.

@keyframes gradient-shift {
  0% { background-position: 0% 50%; }
  50% { background-position: 100% 50%; }
  100% { background-position: 0% 50%; }
}

.btn-animated {
  background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
  background-size: 400% 400%;
  animation: gradient-shift 4s ease infinite;
}

Four colors at 400% background-size gives the animation enough travel distance to feel smooth. At 200% it starts to look like a flip rather than a flow. You can register this as a custom animation in tailwind.config.js under theme.extend.animation and theme.extend.keyframes if you want it as a utility class across your whole project.

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      keyframes: {
        'gradient-shift': {
          '0%, 100%': { 'background-position': '0% 50%' },
          '50%': { 'background-position': '100% 50%' },
        },
      },
      animation: {
        'gradient-shift': 'gradient-shift 4s ease infinite',
      },
    },
  },
};

Then on your button: className="animate-gradient-shift". Clean. The style hub pages on Empire UI — like vaporwave and aurora — use exactly this kind of animated gradient as a design primitive, so if you want visual reference for how far you can push it, those are worth exploring.

Composing a Reusable GradientButton Component

Once you've sorted the animation approach, you want this wrapped in a single component your team can actually use without copying CSS strings around. Here's a production-ready version with variant support:

type GradientVariant = 'violet' | 'sunset' | 'ocean' | 'animated';

const variants: Record<GradientVariant, string> = {
  violet: 'bg-gradient-to-br from-violet-600 to-fuchsia-600 hover:brightness-110',
  sunset: 'bg-gradient-to-r from-orange-500 to-rose-500 hover:brightness-110',
  ocean: 'bg-gradient-to-r from-cyan-500 to-blue-600 hover:brightness-110',
  animated: 'btn-animated',
};

interface GradientButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: GradientVariant;
  size?: 'sm' | 'md' | 'lg';
}

const sizes = {
  sm: 'px-4 py-2 text-sm rounded-lg',
  md: 'px-6 py-3 text-sm rounded-xl',
  lg: 'px-8 py-4 text-base rounded-2xl',
};

export function GradientButton({
  variant = 'violet',
  size = 'md',
  children,
  className = '',
  ...props
}: GradientButtonProps) {
  return (
    <button
      className={`
        ${variants[variant]}
        ${sizes[size]}
        text-white font-semibold
        focus-visible:outline-none focus-visible:ring-2
        focus-visible:ring-white/50 focus-visible:ring-offset-2
        active:scale-95 transition-all duration-150
        disabled:opacity-50 disabled:pointer-events-none
        ${className}
      `}
      {...props}
    >
      {children}
    </button>
  );
}

The disabled:pointer-events-none pair is something people forget until a user rage-clicks a disabled button and triggers the action anyway. Don't skip it. Also notice focus-visible:ring-white/50 instead of a specific color — it adapts to any gradient background without you having to pick a matching ring color per variant.

If you're building this into a design system, also consider a loading prop that renders a spinner and sets aria-busy="true". That's a two-hour addition that makes your button component genuinely production-grade rather than just visually complete.

For more button styles and pre-built components across different visual aesthetics — neobrutalism, glassmorphism, and others — browse components on Empire UI. A lot of the work here is already done.

Performance Considerations Worth Knowing

CSS gradient animations are not free. The background-size and background-position tricks both force the browser to repaint the button on every frame because gradients aren't composited on the GPU by default. For a single hero button, that's fine. For a page with 20 animated gradient buttons, you'll see paint flashing in DevTools.

The fix is will-change: background-position on the animated element, which hints to the browser to promote the layer. Use it sparingly — promoting too many layers eats GPU memory. In 2025, Chrome DevTools finally made layer promotion visible in the Layers panel without needing flags, so you can validate this quickly.

The pseudo-element opacity approach actually performs better in this context because opacity animations *are* GPU-composited. Fading the overlay pseudo-element from 0 to 1 runs on the compositor thread and doesn't trigger layout or paint. That's why the big animation-heavy sites tend to use it over the background-position approach.

Last thing: if you want to explore how other visual styles handle interactive states — hover glows on cyberpunk buttons, soft shadows on neumorphism — those pages show the full spectrum of what's possible without gradient tricks at all. Sometimes the right button for the job isn't a gradient button.

FAQ

Why won't Tailwind's `transition-all` animate my gradient hover state?

CSS gradients are rendered as background-image, and transition doesn't apply to background-image values — only background-color. You need the background-size trick, a pseudo-element overlay, or a @keyframes animation to get actual motion.

Can I use Tailwind's `from-*` and `to-*` utilities and still animate the gradient?

Yes, but only for brightness/opacity changes, not gradient color transitions. Use hover:brightness-110 or the pseudo-element overlay approach if you need color-to-color animation — swapping from-* classes just snaps instantly.

What's the right focus ring for a gradient button with a dark background?

Use focus-visible:ring-2 focus-visible:ring-white/50 focus-visible:ring-offset-2 focus-visible:ring-offset-[your-bg-color]. The offset color should match your page background to create a visible gap between button edge and ring.

Does the animated gradient button hurt performance?

The background-position animation triggers repaints but is manageable for small numbers of buttons. Add will-change: background-position to hint GPU promotion. The pseudo-element opacity approach performs better because opacity runs on the compositor thread.

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

Read next

Aurora UI: How to Build Gradient Aurora Effects in CSS & ReactAurora Background Animation in CSS: Three Techniques ComparedButton Component Variants in Tailwind: Primary, Ghost, Icon, LoadingHow to Build an Animated Button in React + Tailwind (Free)