Hover effects in CSS: a gallery for buttons, cards and images

A hover effect is one property that changes on :hover, plus a transition on the resting state. This gallery shows four of them, and the two rules that keep them working for keyboards and touch screens.

A hover effect in CSS is a :hover rule that changes one property, plus a transition on the element's normal rule so the change eases in and back out. That covers every effect on this page.

The work is in picking the right property and making the effect reach keyboard and touch readers too.

Start with the two smallest ones. Hover the button and the link with a mouse, then press Tab to reach them with the keyboard.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hover effects: lift and underline</title>
<style>
  body { margin: 0; padding: 24px 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .row { display: flex; flex-wrap: wrap; gap: 18px; align-items: center; }

  /* 1. Lift: move up and deepen the shadow */
  .lift {
    font: 600 16px system-ui, sans-serif; color: #fff; background: #2563eb;
    border: 0; border-radius: 10px; padding: 12px 22px; cursor: pointer;
    box-shadow: 0 2px 6px rgba(0, 0, 0, .15);
    transition: transform .2s ease, box-shadow .2s ease;  /* on the resting state */
  }

  /* 2. Underline grow: a 2px background line from 0% to 100% wide */
  .grow {
    font-size: 17px; color: #1d4ed8; text-decoration: none;
    background: linear-gradient(currentColor, currentColor) left bottom / 0% 2px no-repeat;
    transition: background-size .25s ease;
  }

  /* Hover only where the main pointer can hover (not on touch screens) */
  @media (hover: hover) {
    .lift:hover { transform: translateY(-3px); box-shadow: 0 10px 20px rgba(0, 0, 0, .2); }
    .grow:hover { background-size: 100% 2px; }
  }

  /* The same look for keyboard focus, on every device */
  .lift:focus-visible { transform: translateY(-3px); box-shadow: 0 10px 20px rgba(0, 0, 0, .2); }
  .grow:focus-visible { background-size: 100% 2px; }
  :focus-visible { outline: 3px solid #f59e0b; outline-offset: 3px; }

  p { font-size: 14px; color: #4b5563; margin: 22px 0 0; line-height: 1.5; }
  #mode { color: #0f5132; }
</style>
</head>
<body>
<div class="row">
  <button class="lift" type="button">Lift button</button>
  <a class="grow" href="#">Underline grows</a>
</div>
<p>Hover with a mouse, or press Tab to focus them.<br>
This screen: <b id="mode"></b></p>

<script>
  // Show which hover media query matches on this device
  const mq = matchMedia('(hover: hover)');
  const show = () => {
    document.getElementById('mode').textContent = mq.matches
      ? '(hover: hover), hover effects on'
      : '(hover: none), hover effects off';
  };
  show();
  mq.addEventListener('change', show);
</script>
</body>
</html>
A lifting button and a growing underline. The line at the bottom shows which hover media query matches your screen.

The pattern behind every effect

Each effect has three parts. The resting rule holds the transition. The :hover rule changes the property. A :focus-visible rule gives keyboard readers the same change.

Effect Property that changes Used on
Lift transform and box-shadow Buttons, cards
Underline grow background-size Text links
Image zoom transform: scale() on the image Photos, thumbnails
Overlay caption transform: translateY() on the caption Gallery tiles

All four change transform, box-shadow or a background. None of them changes width, height or margin, so the elements around them never move. Why the transition belongs on the resting rule, and not inside :hover, is covered in CSS hover transition.

Lift: move up, deepen the shadow

A button that rises a few pixels and casts a larger shadow reads as something you can press. Two properties do it:

.lift {
  box-shadow: 0 2px 6px rgba(0, 0, 0, .15);
  transition: transform .2s ease, box-shadow .2s ease;
}
.lift:hover {
  transform: translateY(-3px);
  box-shadow: 0 10px 20px rgba(0, 0, 0, .2);
}

Keep the distance small. A few pixels feels like a response, and a larger jump pulls the button away from the pointer. The same rule works on a whole card, as the finished gallery below shows.

Underline that grows from the left

The text-decoration underline has no length you can grow from zero. Draw the line as a background instead: a gradient of one colour, 2 pixels tall, 0% wide at rest and 100% wide on hover.

.grow {
  text-decoration: none;
  background: linear-gradient(currentColor, currentColor)
              left bottom / 0% 2px no-repeat;
  transition: background-size .25s ease;
}
.grow:hover { background-size: 100% 2px; }

currentColor makes the line match the link text, so it follows any colour you set on hover. To start the line from the centre, change left bottom to center bottom. For colour-only link effects, see HTML link hover color.

Image zoom and overlay caption

The next two work on pictures. The tiles below use CSS gradients in place of photos, and the rules are the same for an <img>.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hover effects: image zoom and overlay caption</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 14px; max-width: 520px; }

  .tile {
    display: block; position: relative; aspect-ratio: 4 / 3;
    border-radius: 12px; color: #fff; text-decoration: none;
    overflow: hidden;  /* clips the zoomed image and the hidden caption */
  }
  .pic { width: 100%; height: 100%; transition: transform .4s ease; }
  .a { background: linear-gradient(160deg, #fde68a, #f97316 55%, #7c2d12); }
  .b { background: linear-gradient(200deg, #bae6fd, #0ea5e9 50%, #1e3a8a); }
  .label { position: absolute; top: 8px; left: 10px; font: 700 12px system-ui, sans-serif;
           background: rgba(0, 0, 0, .45); padding: 3px 7px; border-radius: 6px; }

  /* Caption is visible by default, so touch screens always show it */
  .cap { position: absolute; left: 0; right: 0; bottom: 0; padding: 10px 12px; font-size: 14px;
         background: rgba(15, 23, 42, .78); transition: transform .3s ease; }

  @media (hover: hover) {
    .zoom:hover .pic { transform: scale(1.12); }
    .over .cap { transform: translateY(100%); }   /* parked just below the tile */
    .over:hover .cap { transform: translateY(0); }
  }
  .zoom:focus-visible .pic { transform: scale(1.12); }
  .over:focus-visible .cap { transform: translateY(0); }
  .tile:focus-visible { outline: 3px solid #f59e0b; outline-offset: 3px; }

  p { font-size: 13px; color: #4b5563; margin: 12px 0 0; }
</style>
</head>
<body>
<div class="grid">
  <a class="tile zoom" href="#">
    <div class="pic a" role="img" aria-label="Orange sunset"></div>
    <span class="label">Image zoom</span>
  </a>
  <a class="tile over" href="#">
    <div class="pic b" role="img" aria-label="Blue sea"></div>
    <span class="label">Overlay caption</span>
    <span class="cap">Harbour at dawn, 6:10 am</span>
  </a>
</div>
<p>Hover, or press Tab to reach a tile. On a touch screen the caption is always shown.</p>
</body>
</html>
Left: the picture grows inside its frame. Right: a caption slides up from below the tile.

The zoom scales the image, not the tile. The tile has overflow: hidden, so the picture grows inside the same box and nothing around it moves.

Scaling the tile itself spills over its neighbour. Scaling the image inside a clipping frame does not.
Scaling the tile itself spills over its neighbour. Scaling the image inside a clipping frame does not.
.tile { overflow: hidden; border-radius: 12px; }
.tile img { transition: transform .4s ease; }
.tile:hover img { transform: scale(1.12); }

The caption uses the same clipping. It is positioned at the bottom of the tile and pushed down by its own height with translateY(100%), which parks it just outside the visible box. On hover it moves back to zero.

Keyboard focus gets the same effect

A :hover rule does nothing for someone moving through the page with the Tab key. Give the element the same change under :focus-visible, and keep a visible outline so the reader can see where focus is.

With only :hover, a Tab press shows no change. Adding :focus-visible gives keyboard readers the same lift.
With only :hover, a Tab press shows no change. Adding :focus-visible gives keyboard readers the same lift.
.lift:focus-visible {
  transform: translateY(-3px);
  outline: 3px solid #f59e0b;
  outline-offset: 3px;
}

:focus-visible is the right choice over :focus here. Browsers normally do not match it after a mouse click on a button, so mouse readers do not see an outline appear on every click.

For a card that wraps an image and a link, put the rule on the link element itself. For the outline options, see CSS outline.

Touch screens: the hover media query

A phone has no pointer that rests over an element. A tap can apply :hover for a moment, or leave it stuck until the reader taps somewhere else. An effect that hides content until hover can hide it from touch readers for good.

If the caption only appears on hover, touch readers never see it. Hide it only inside the hover media query.
If the caption only appears on hover, touch readers never see it. Hide it only inside the hover media query.

The fix is the hover media feature. (hover: hover) matches when the main input can hover, such as a mouse or trackpad. On a touch screen it does not match, so the rules inside it are skipped.

.cap { transform: none; }            /* touch: always visible */

@media (hover: hover) {
  .cap { transform: translateY(100%); }
  .tile:hover .cap { transform: none; }
}

Write the resting state as the version that works without hover, and add the hiding and revealing only inside the query.

The first example on this page prints which one your screen matches. There is also any-hover, which matches if any connected input can hover. More on media features in media queries.

The last example puts the four effects on one card. The card lifts, the image zooms, the caption slides in and the "View photos" link underlines, all from one hover or one keyboard focus.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hover effect gallery</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #eef1f5; color: #1d2330; }
  .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 16px; }

  .card {
    display: block; background: #fff; border-radius: 14px; overflow: hidden;
    color: inherit; text-decoration: none; box-shadow: 0 2px 6px rgba(0, 0, 0, .08);
    transition: transform .2s ease, box-shadow .2s ease;
  }
  .frame { position: relative; overflow: hidden; aspect-ratio: 4 / 3; }
  .pic { height: 100%; transition: transform .4s ease; }
  .cap { position: absolute; left: 0; right: 0; bottom: 0; padding: 8px 10px; font-size: 13px;
         color: #fff; background: rgba(15, 23, 42, .78); transition: transform .3s ease; }
  .text { padding: 10px 12px 14px; }
  .text h3 { margin: 0 0 6px; font-size: 15px; }
  .more { font-size: 14px; color: #1d4ed8;
          background: linear-gradient(currentColor, currentColor) left bottom / 0% 2px no-repeat;
          transition: background-size .25s ease; }

  /* Mouse and trackpad: all four effects on hover */
  @media (hover: hover) {
    .cap { transform: translateY(100%); }
    .card:hover { transform: translateY(-4px); box-shadow: 0 12px 24px rgba(0, 0, 0, .16); }
    .card:hover .pic { transform: scale(1.1); }
    .card:hover .cap { transform: none; }
    .card:hover .more { background-size: 100% 2px; }
  }

  /* Keyboard: the same four effects, plus an outline */
  .card:focus-visible { transform: translateY(-4px); box-shadow: 0 12px 24px rgba(0, 0, 0, .16);
                        outline: 3px solid #f59e0b; outline-offset: 3px; }
  .card:focus-visible .pic { transform: scale(1.1); }
  .card:focus-visible .cap { transform: none; }
  .card:focus-visible .more { background-size: 100% 2px; }

  /* Reduced motion: no lift, no zoom, no sliding. Caption and underline just appear. */
  @media (prefers-reduced-motion: reduce) {
    .card, .pic, .cap, .more { transition: none; }
    .card:hover, .card:focus-visible, .card:hover .pic, .card:focus-visible .pic { transform: none; }
  }
</style>
</head>
<body>
<div class="grid">
  <a class="card" href="#">
    <div class="frame">
      <div class="pic" style="background: linear-gradient(160deg, #fde68a, #f97316 55%, #7c2d12)" role="img" aria-label="Desert"></div>
      <span class="cap">Dunes, late afternoon</span>
    </div>
    <div class="text"><h3>Desert</h3><span class="more">View photos</span></div>
  </a>
  <a class="card" href="#">
    <div class="frame">
      <div class="pic" style="background: linear-gradient(200deg, #bae6fd, #0ea5e9 50%, #1e3a8a)" role="img" aria-label="Coast"></div>
      <span class="cap">Harbour at dawn</span>
    </div>
    <div class="text"><h3>Coast</h3><span class="more">View photos</span></div>
  </a>
  <a class="card" href="#">
    <div class="frame">
      <div class="pic" style="background: linear-gradient(170deg, #d9f99d, #22c55e 50%, #14532d)" role="img" aria-label="Forest"></div>
      <span class="cap">Pine trail after rain</span>
    </div>
    <div class="text"><h3>Forest</h3><span class="more">View photos</span></div>
  </a>
</div>
</body>
</html>
Hover a card or Tab to it. On a touch screen the captions are always shown. With reduced motion turned on, nothing moves and the caption and underline simply appear.
  • One trigger: every rule starts from .card:hover or .card:focus-visible, so the parts move together.
  • Two clips: the card and the image frame both have overflow: hidden, so the zoom and the caption stay inside.
  • Reduced motion: readers who ask their system for less motion get no lift and no zoom.
@media (prefers-reduced-motion: reduce) {
  .card, .pic, .cap, .more { transition: none; }
  .card:hover, .card:hover .pic { transform: none; }
}

The layout of the cards themselves is covered in HTML card layout, and a full photo grid in HTML image gallery.

When it does not work

What you see Cause Fix
The effect snaps back with no easing transition is inside the :hover rule Move it to the resting rule
transform does nothing on a link An inline link cannot be transformed Set display: inline-block
The zoomed image covers its neighbour Nothing clips the scale overflow: hidden on the frame
Neighbours shift when you hover The hover changes width, padding or margin Use transform instead
A tapped card stays lifted on a phone Touch applied :hover and kept it Wrap hover rules in (hover: hover)
Keyboard readers see no change The effect only exists on :hover Add the same :focus-visible rule
The caption never shows on a phone It is hidden outside the media query Hide it only inside (hover: hover)

Hover effects have to be felt with a pointer. A screenshot shows one frozen state, and an .html attachment may open as plain code, or not at all, on a phone.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written, so the people you send it to can hover the cards and Tab through them themselves. If you change the code later, the same link shows the new version.

Questions people ask

How do I add a hover effect in CSS?

Write the changed look in a :hover rule, for example .btn:hover { transform: translateY(-3px); }, and put transition: transform .2s on the plain .btn rule. The transition on the resting state makes the change ease in and ease back out.

Why does my hover effect not work on a phone?

A touch screen has no pointer resting over the element, so there is no real hover state. A tap can apply :hover briefly or leave it stuck until the reader taps elsewhere. Put hover rules inside @media (hover: hover) and make sure the resting state already shows everything important.

Should keyboard focus get the same effect as hover?

Yes. Repeat the effect in a :focus-visible rule and add a visible outline. Keyboard readers then get the same feedback as mouse readers, and browsers normally do not match :focus-visible after a mouse click on a button.

Why does my image zoom spill over the next image?

The scale is on an element with nothing clipping it. Put the image in a frame with overflow: hidden and scale the image inside, not the frame.

Can I turn hover animations off for people who get motion sick?

Yes. Inside @media (prefers-reduced-motion: reduce), set transition: none and drop the transform changes. Keep the colour change or the caption, so the effect still gives feedback without movement.

Keep reading