Background Properties: Four Hover Effects, Same Formula

Geoff recently covered a hover effect built with pseudo-elements, transforms, and transitions. But the same visual can be achieved entirely with CSS background properties — often with leaner, more maintainable code. This article runs through four variations of that idea, each relying on the same core ingredients: background shorthand, custom properties, and calc().

Effect #1: The Classic Slide

The first effect reproduces Geoff’s original result. Without the optional color transition, it needs only three CSS declarations:

.hover-1 {
  background: linear-gradient(#1095c1 0 0) var(--p, 0) / var(--p, 0) no-repeat;
  transition: .4s, background-position 0s;
}
.hover-1:hover {
  --p: 100%;
  color: #fff;
}

Start with a simple background-size transition: animate a linear gradient’s width from 0 to 100% while keeping its height fixed.

First, trim the gradient definition. Two identical color stops do the same job as an explicit start and end color:

background-image: linear-gradient(#1095c1 0 0);

With background-size, the height can be omitted because gradients are full height by default, so transition from background-size: 0 to background-size: 100%:

.hover-1 {
  background-image: linear-gradient(#1095c1 0 0);
  background-size: 0;
  background-repeat: no-repeat;
  transition: .4s;
}
.hover-1:hover {
  background-size: 100%;
}

A custom property removes the repetition of background-size. When --p is undefined, its fallback (0%) applies; on hover, we override it with 100%:

.hover-1 {
  background-image: linear-gradient(#1095c1 0 0);
  background-size: var(--p, 0%);
  background-repeat: no-repeat;
  transition: .4s;
}
.hover-1:hover {
  --p: 100%;
}

Now combine everything into the background shorthand. A background-position value becomes mandatory when you include size in the shorthand, and this effect needs it anyway:

.hover-1 {
  background: linear-gradient(#1095c1 0 0) left / var(--p, 0%) no-repeat;
  transition: .4s;
}
.hover-1:hover {
  --p: 100%;
}

The slide direction requires a position change on hover: increase size from the right on mouse enter, then decrease from the left on mouse out. That means setting background-position: right on hover and giving background-position a 0s transition duration.

We can simplify further. Replace left/right with 0 and 100% (percentages assume full height, so a single value works):

.hover-1 {
  background: linear-gradient(#1095c1 0 0) 0 / var(--p, 0%) no-repeat;
  transition: .4s, background-position 0s;
}
.hover-1:hover {
  --p: 100%;
  background-position: 100%;
}

Now background-position and --p share the same values, so one custom property controls both:

.hover-1 {
  background: linear-gradient(#1095c1 0 0) var(--p, 0%) / var(--p,0%) no-repeat;
  transition: .4s, background-position 0s;
}
.hover-1:hover {
  --p: 100%;
}

To flip the direction (start from left, end at right), express the position with calc(). Let --p go from 0% to 100% while the position moves the opposite way:

.hover-1 {
  background: linear-gradient(#1095c1 0 0) calc(100% - var(--p,0%)) / var(--p,0%) no-repeat;
  transition: .4s, background-position 0s;
}
.hover-1:hover {
  --p: 100%;
}

Note the unit: always write 0% (not bare 0) for custom properties used inside calc(), where the unit must be explicit.

Effect #2: Two-Stage Reveal

The second effect layers a position transition and a size transition in sequence. The diagram breaks it down: first the background moves, then it grows.

Diagram showing the hover effect in three pieces.
Initially, a fixed-height, full-width gradient is outside of view. Then we move the gradient to the right to cover the bottom side. Finally, we increase the size of the gradient from the fixed height to 100% to cover the whole element.

The transition property needs two values — position first (with a delay on size) on hover, reversed on mouse out:

.hover-2 {
  background-image: linear-gradient(#1095c1 0 0);
  background-size: 100% .08em; /* .08em is our fixed height; modify as needed. */
  background-position: /* ??? */;
  background-repeat: no-repeat;
  transition: background-size .3s, background-position .3s .3s;
}
.hover-2:hover {
  transition: background-size .3s .3s, background-position .3s;
  background-size: 100% 100%;
  background-position: /* ??? */;
}

The tricky part is moving a gradient whose width is 100%. Percentage values in background-position behave counter-intuitively: they don’t just shift the image. To actually move a full-width gradient, widen it first. Setting the width to 200% lets percentage positions slide it around (the overflow is hidden anyway):

.hover-2 {
  background-image: linear-gradient(#1095c1 0 0);
  background-size: 200% .08em;
  background-position: 200% 100%;
  background-repeat: no-repeat;
  transition: background-size .3s, background-position .3s .3s;
}
.hover-2:hover {
  transition: background-size .3s .3s, background-position .3s;
  background-size: 200% 100%;
  background-position: 100% 100%;
}

Applying the same optimization playbook — shorthand, a custom property --p for size/position, and another variable --t for the transition curve — gives us:

.hover-2 {
  background: 
    linear-gradient(#1095c1 0 0) no-repeat
    var(--p, 200%) 100% / 200% var(--p, .08em);
  transition: .3s var(--t, 0s), background-position .3s calc(.3s - var(--t, 0s));
}
.hover-2:hover {
  --p: 100%;
  --t: .3s;
}

On hover, --t is set to .3s; on mouse out, the fallback 0s .3s applies:

transition: .3s .3s, background-position .3s 0s;
transition: .3s 0s, background-position .3s .3s;

Omitted properties in a transition mean “all,” so the first --t value covers both background-size and background-position, with the explicit position value layered on top. That’s fine here, but if you change more than two properties on hover, this shortcut may not be suitable.

One custom property can still drive everything, using a “switch” technique: --i is 0 by default and 1 on hover, with each property computed from it via calc(). That keeps the code to three declarations:

.hover-2 {
  background: 
    linear-gradient(#1095c1 0 0) no-repeat
    calc(200% - var(--i, 0) * 100%) 100% / 200% calc(100% * var(--i, 0) + .08em);
  transition: .3s calc(var(--i, 0) * .3s), background-position .3s calc(.3s - calc(var(--i, 0) * .3s));
}
.hover-2:hover {
  --i: 1;
}

Effect #3: Double Gradient Split

Two gradients working together can create more complex reveals. The diagram shows a wipe effect where two half-width gradients meet in the middle.

We initially have two gradients that overflow the element so that they are out of view. Each one has a fixed height and toes up half of the element’s width. Then we slide them into view to make them visible. The first gradient is placed at the bottom-left and the second one at the top-right. Finally, we increase the height to cover the whole element.

The CSS differs only in having two gradients at different positions:

.hover-3 {
  background-image:
    linear-gradient(#1095c1 0 0),
    linear-gradient(#1095c1 0 0);
  background-repeat: no-repeat;
  background-size: 50% .08em;
  background-position:
    -100% 100%,
    200% 0;
  transition: background-size .3s, background-position .3s .3s;
}
.hover-3:hover {
  background-size: 50% 100%;
  background-position:
    0 100%,
    100% 0;  
  transition: background-size .3s .3s, background-position .3s;
}

Optimized with shorthand, custom properties, and calc(), the code shrinks again. Note the extra --c variable for the shared gradient color. Also, small offsets — using 50.1% for size and adding 1% to positions — prevent a visible gap between the two halves:

.hover-3 {
  --c: no-repeat linear-gradient(#1095c1 0 0);
  background: 
    var(--c) calc(-100% + var(--p, 0%)) 100% / 50% var(--p, .08em),
    var(--c) calc( 200% - var(--p, 0%)) 0    / 50% var(--p, .08em);
  transition: .3s var(--t, 0s), background-position .3s calc(.3s - var(--t, 0s));
}
.hover-3:hover {
  --p: 100%;
  --t: 0.3s;
}

The switch variable reduces it further to one custom property:

.hover-3 {
  --c: no-repeat linear-gradient(#1095c1 0 0);
  background: 
    var(--c) calc(-100% + var(--i, 0) * 100%) 100% / 50% calc(100% * var(--i, 0) + .08em),
    var(--c) calc( 200% - var(--i, 0) * 100%) 0 / 50% calc(100% * var(--i, 0) + .08em);
  transition: .3s calc(var(--i, 0) * .3s), background-position .3s calc(.3s - var(--i, 0) * .3s);
}
.hover-3:hover {
  --i: 1;
}

Effect #4: Diagonals with Conic Gradients

The final effect raises the difficulty with two conic-gradients and more math. The concept: both gradients start at zero size, grow to cover the element, then slide down. Because they share the same color scheme, the position change is invisible until the size shrinks again on mouse out, creating a diagonal wipe.

The positions are straightforward: one gradient runs from top left (0 0) to bottom left (0 100%); the other from top right (100% 0) to bottom right (100% 100%).

Transitions run on background-size with background-position set to 0s. Initial sizes are 0% 200% — zero width, double the element height.

Defining the conic gradients requires knowing the element’s height, so a fixed line-height is used and referenced in the gradient values:

.hover-4 {
  background-image:
    conic-gradient(/* ??? */),
    conic-gradient(/* ??? */);
  background-position:
    0 0,
    100% 0;
  background-size: 0% 200%;
  background-repeat: no-repeat;
  transition: background-size .4s, background-position 0s;
}
.hover-4:hover {
  background-size: /* ??? */ 200%;
  background-position:
    0 100%,
    100% 100%;
}

A common mistake is giving each gradient half the width. That leaves a gap equal to the element’s height, as shown:

.hover-4 {
  --c: #1095c1;
  line-height: 1.2em;
  background-image:
    conic-gradient(from -135deg at 100%  50%, var(--c) 90deg, #0000 0),
    conic-gradient(from -135deg at 1.2em 50%, #0000 90deg, var(--c) 0);
  background-position:
    0 0,
    100% 0;
  background-size: 0% 200%;
  background-repeat: no-repeat;
  transition: background-size .4s, background-position 0s;
}
.hover-4:hover {
  background-size: /* ??? */ 200%;
  background-position:
    0 100%,
    100% 100%;
}
We’re left with a large gap if we use 50% as the background-size value for both gradients.

The fix: each gradient must grow by half the height on hover to fully cover the element:

.hover-4:hover {
  background-size: calc(50% + .6em) 200%;
  background-position:
    0 100%,
    100% 100%;
}

The optimized version follows the same pattern as the previous effects:

.hover-4 {
  --c: #1095c1;
  line-height: 1.2em;
  background:
    conic-gradient(from -135deg at 100%  50%, var(--c) 90deg, #0000 0) 
      0  var(--p, 0%) / var(--s, 0%) 200% no-repeat,
    conic-gradient(from -135deg at 1.2em 50%, #0000 90deg, var(--c) 0) 
      100% var(--p, 0%) / var(--s, 0%) 200% no-repeat;
  transition: .4s, background-position 0s;
}
.hover-4:hover {
  --p: 100%;
  --s: calc(50% + .6em);
}

Reworking this final effect to a single custom property is left as an exercise — after four examples, the pattern should be clear.

One Approach, Many Effects

Each of these four effects is visually distinct, yet all share the same implementation strategy: a few background declarations, custom properties as controls, and calc() to translate one variable into several property values. The result is small, maintainable CSS that avoids pseudo-elements entirely.