CSS Ghost Button
An outlined button that fills with color from left to right on hover using a pseudo-element.
A CSS ghost button is an outlined control with no background until the pointer arrives, at which point a color sweeps across it. The sweep is a ::before pseudo-element parked just outside the button and slid into place with transform, not a background color being transitioned. That distinction is the whole technique: a color change happens everywhere at once, and a sweep has a direction.
Three buttons below share one rule, with the third reversing the direction of the sweep. Two more builds follow: a fill that grows upward from the bottom edge using scaleY and transform-origin instead of a slide, and a diagonal wipe made from a skewed pseudo-element wider than the button holding it.
Hover to fill
HTML
<div class="ghost-btns">
<button class="ghost-btn ghost-btn-green"><span>Hover me →</span></button>
<button class="ghost-btn ghost-btn-blue"><span>Fill sweep</span></button>
<button class="ghost-btn ghost-btn-pink"><span>Right to left</span></button>
</div>
CSS
.ghost-btns {
display: flex;
gap: 1rem;
align-items: center;
justify-content: center;
flex-wrap: wrap;
padding: .5rem;
}
.ghost-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
padding: .7rem 1.75rem;
border: 2px solid;
border-radius: 8px;
background: transparent;
font-family: system-ui, sans-serif;
font-size: .95rem;
font-weight: 600;
cursor: pointer;
overflow: hidden;
transition: color .3s;
}
/* the label rides above the sweeping fill */
.ghost-btn span {
position: relative;
z-index: 1;
}
.ghost-btn::before {
content: '';
position: absolute;
inset: 0;
transform: translateX(-101%);
transition: transform .3s cubic-bezier(0.4, 0, 0.2, 1);
}
.ghost-btn:hover::before {
transform: translateX(0);
}
/* Green variant */
.ghost-btn-green { border-color: #b8ff57; color: #b8ff57; }
.ghost-btn-green::before { background: #b8ff57; }
.ghost-btn-green:hover { color: #0c0c0d; }
/* Blue variant */
.ghost-btn-blue { border-color: #38bdf8; color: #38bdf8; }
.ghost-btn-blue::before { background: #38bdf8; }
.ghost-btn-blue:hover { color: #0c0c0d; }
/* Pink variant, sweeping in from the right instead */
.ghost-btn-pink { border-color: #f472b6; color: #f472b6; }
.ghost-btn-pink::before {
background: #f472b6;
transform: translateX(101%);
}
.ghost-btn-pink:hover::before { transform: translateX(0); }
.ghost-btn-pink:hover { color: #fff; }
Other ways to build it
Growing from an edge with scaleY
Swapping the translate for a scale changes what the fill does at the boundary. A slide moves a full sized block into view; a scale stretches a flat one out from whichever edge transform-origin names, so the fill appears to rise rather than to arrive. The first button here grows from the bottom, the second from the center outward, and the third fills from the left. All three are the same rule with one different transform-origin, and every one of them also runs on :focus-visible so a keyboard reaches them.
HTML
<div class="ghost-btns">
<button class="gb-grow"><span>From the bottom</span></button>
<button class="gb-grow gb-grow-mid"><span>From the center</span></button>
<button class="gb-grow gb-grow-left"><span>From the left</span></button>
</div>
CSS
.gb-grow {
position: relative;
overflow: hidden;
padding: .7rem 1.75rem;
border: 2px solid #57d9a3;
border-radius: 8px;
background: transparent;
color: #57d9a3;
font-size: .95rem;
font-weight: 600;
cursor: pointer;
transition: color .3s ease;
}
.gb-grow span {
position: relative;
z-index: 1;
}
.gb-grow::before {
content: '';
position: absolute;
inset: 0;
background: #57d9a3;
/* scaleY(0) collapses the fill against the origin edge */
transform: scaleY(0);
transform-origin: bottom;
transition: transform .35s cubic-bezier(0.4, 0, 0.2, 1);
}
.gb-grow:hover::before,
.gb-grow:focus-visible::before {
transform: scaleY(1);
}
.gb-grow:hover,
.gb-grow:focus-visible {
color: #0c0c0d;
}
.gb-grow-mid::before { transform-origin: center; }
.gb-grow-left::before { transform: scaleX(0); transform-origin: left; }
.gb-grow-left:hover::before,
.gb-grow-left:focus-visible::before { transform: scaleX(1); }
/* keeps the color change, loses the travel */
@media (prefers-reduced-motion: reduce) {
.gb-grow::before {
transform: none;
opacity: 0;
transition: opacity .2s ease;
}
.gb-grow:hover::before,
.gb-grow:focus-visible::before { opacity: 1; }
}
A diagonal wipe from a skewed pseudo-element
A straight vertical edge crossing a button is a wipe. Tilt that edge and it becomes a swipe, which suits a wider control where a flat front would take a long time to cross. The fill is skewed by 22 degrees and made 140% of the button's width with a 20% overhang on each side, because a skewed rectangle is narrower at the top and bottom than in the middle and would otherwise leave a triangle of the button uncovered at either corner. The skew is repeated in both transform values, since a later transform replaces the earlier one rather than adding to it.
HTML
<div class="ghost-btns">
<button class="gb-diag"><span>Diagonal wipe</span></button>
<button class="gb-diag gb-diag-violet"><span>Skewed 22 degrees</span></button>
</div>
CSS
.gb-diag::before {
content: '';
position: absolute;
/* wider than the button, and overhanging top and bottom, so the
skewed corners never leave a gap */
top: -20%;
bottom: -20%;
left: -20%;
width: 140%;
background: #ffd444;
transform: translateX(-120%) skewX(-22deg);
transition: transform .4s cubic-bezier(0.4, 0, 0.2, 1);
}
/* the skew is repeated because transform is one property: the second
value replaces the first rather than adding to it */
.gb-diag:hover::before,
.gb-diag:focus-visible::before {
transform: translateX(0) skewX(-22deg);
}
.gb-diag:hover,
.gb-diag:focus-visible {
color: #0c0c0d;
}
How it works
A ::before pseudo-element is positioned absolutely inside the button, which sets overflow: hidden. Starting it at translateX(-101%) puts it entirely outside the button on the left. On hover it transitions to translateX(0), sweeping the fill across. The label sits in a <span> with position: relative and z-index: 1 so it stays above the pseudo-element, and the button's own color transitions at the same time.
Transitioning background-color would be one declaration shorter and much duller. The whole surface changes at the same rate, so it reads as a light being switched on, and there is nothing to connect the change to the fact that a pointer just arrived. A sweep travels, and travel implies cause. It is also cheaper than it looks: the pseudo-element is painted once and then moved with transform, which the compositor handles, whereas an animated background-color repaints the button on every frame.
The starting offset is -101% rather than -100% on purpose. At exactly 100% the pseudo-element's right edge and the button's left edge sit on the same coordinate, and on a display where that coordinate lands between device pixels the browser antialiases both, leaving a one pixel seam of fill color visible along the edge of a button that is supposed to be empty. The extra percent moves it clear. The same defensive rounding shows up wherever two elements are meant to touch exactly and one of them is animated.
The label has to be positioned. Within one stacking context a positioned element paints after ordinary in-flow content, so a pseudo-element with position: absolute covers a plain text node no matter what order they appear in the markup. Wrapping the label in a <span> with position: relative and z-index: 1 puts it back on top. This is the reason the markup has a span in it at all, and removing the span makes the button appear to go blank on hover.
overflow: hidden on the button does two jobs. It hides the pseudo-element while it is parked outside, and it clips the fill to the button's border-radius, which is what stops a rounded button filling with a square block of color. It is worth remembering that the same declaration will also clip anything else you put inside the button later, including a tooltip or a badge that was meant to hang over the edge.
Changing direction is one value. translateX(101%) starts the fill on the right, translateY(101%) starts it below, and swapping the translate for scaleX(0) with a transform-origin turns the slide into a growth from an edge or from the center. Both variants below are that single substitution. For an outline that draws itself rather than a surface that fills, see the border draw button.
CSS properties used
::before- The fill itself. It needs
content: ''to exist at all, andposition: absolutewithinset: 0to match the button's box. transform- Moves the fill into place.
translateXslides it,scaleXorscaleYgrows it, and askewXon top turns a straight edge into a diagonal one. transform-origin- Decides which edge a scaling fill grows from.
bottomfills upward,leftfills rightward, the default center grows from the middle outward. overflowhiddenon the button hides the fill while it is parked outside and clips it to the corner radius once it arrives.z-index1on the label, withposition: relativeto make it apply. Without both, the absolutely positioned fill paints over the text.transition- On the fill for the sweep and on the button for the label color. Matching the two durations is what keeps the text readable through the middle of the sweep.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
::before | 4 | 2 | 3.1 | 12 |
transition | 4 | 5 | 5.1 | 12 |
transform | 4 | 3.5 | 3.1 | 12 |
:focus-visible | 86 | 4 | 15.4 | 86 |
prefers-reduced-motion | 74 | 63 | 10.1 | 79 |
Figures come from Can I Use. Nothing here needs a fallback. Safari only supports :focus-visible from 15.4, so if older Safari matters to your audience, add a plain :focus rule beside it and accept that mouse users will occasionally see a focus ring. The border: 2px solid with no color named inherits currentColor, which is what lets each variant set one color and get a matching border, text and fill.
Accessibility notes
Halfway through the sweep the label is sitting on two backgrounds at once. A color that clears the contrast ratio against the empty button and against the filled one can still fail across the boundary for the couple of hundred milliseconds it takes to cross. Either keep the label color fixed and choose a fill that works behind it, or hold the color change until the fill is most of the way across with a transition-delay.
Add :focus-visible next to :hover so the sweep runs for keyboard users. A ghost button is a particularly bad candidate for a hover only state, because its resting appearance is deliberately quiet and a reader tabbing through the page has no other cue that they have landed on it.
On touch there is no hover, so the resting state is the only state most mobile readers will ever see. That is fine for a ghost button, whose outline and label are legible on their own, and it is worth checking that the outline itself clears 3:1 against the page background rather than relying on the fill to make the control visible.
Under prefers-reduced-motion: reduce, keep the fill and drop the travel. Setting the pseudo-element to transform: none with an opacity transition gives the same color change with no lateral movement, which preserves the feedback without the motion.
What you can build with it
- Hero calls to action. An outlined button over a photograph, where a solid fill at rest would fight the image but a fill on hover reads as confirmation.
- Secondary actions. Cancel and back buttons that need to be findable without competing with the primary action beside them.
- Navigation and tabs. The same sweep on a tab strip, where the fill doubles as the indication of which tab the pointer is over.
- Pricing and plan cards. A quiet outlined button per tier that fills in the tier's own color, driven by one custom property per card.
- Filter and tag chips. A row of outlined chips where the sweep on hover previews the selected state the chip will take when clicked.
Mistakes worth avoiding
- Forgetting
content: ''on the pseudo-element. It has no default content value, so without it the element is never generated and the button simply never fills, with nothing in the inspector to look at. - Leaving the label unpositioned. The absolutely positioned fill paints over ordinary in-flow content, so the text disappears under the color as the sweep arrives.
- Starting the fill at exactly
-100%. On a fractional device pixel ratio a hairline of the fill stays visible along the button's edge. Use 101%. - Omitting
overflow: hidden. The fill is visible outside the button while it is parked, so a row of ghost buttons shows a stripe of color to the left of each one. - Transitioning
background-coloron the button as well as moving the pseudo-element. The two changes fight, and the result is a fill that arrives and then abruptly changes shade when the background transition catches up.
Frequently asked questions
How do you make a button fill on hover in CSS?
position: relative and overflow: hidden, add a ::before with content: '', position: absolute and inset: 0, park it at transform: translateX(-101%), and transition it to translateX(0) on hover. Put the label in a positioned span so it stays on top.Why does my button text disappear on hover?
position: relative and z-index: 1.Why is there a thin line of color on my button before I hover it?
-100%, so its edge lands on the button's edge and antialiasing leaves a sliver visible on some screens. Move it to -101%.Can the fill come from the right or from the center?
translateX(101%) for right to left, or replace the translate with scaleX(0) and set transform-origin to left, right or the default center. The variants on this page use scaleY and a skewed translate.