CSS Confirm Button
A two-stage destructive action button that uses a checkbox to reveal the confirm step.
A CSS confirm button puts a second step between a click and a destructive action, and the whole thing runs on one hidden checkbox. The first control is a <label> rather than a button, so clicking it checks the box. A :checked rule then reveals the confirmation panel and dims the original control. Cancel is a second label pointing at the same checkbox, which unchecks it and puts everything back.
The reveal is pure CSS. What CSS cannot do is the deleting: the Yes button in every version on this page is an ordinary button that needs a form or a script behind it to actually do anything. Two more versions follow the code below. One fixes the keyboard problem that the version above has, and one swaps the two states in place so nothing underneath moves.
Two stages, one checkbox
HTML
<div class="confirm-wrap">
<input type="checkbox" id="confirm-toggle" class="confirm-input">
<label for="confirm-toggle" class="btn-trigger btn-danger">🗑 Delete Account</label>
<div class="confirm-panel">
<p>Are you sure?</p>
<button class="btn-yes">Yes, delete</button>
<label for="confirm-toggle" class="btn-cancel">Cancel</label>
</div>
</div>
CSS
.confirm-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.25rem;
}
.confirm-input {
display: none;
}
.btn-trigger {
display: inline-flex;
align-items: center;
gap: .5rem;
padding: .7rem 1.5rem;
border-radius: 8px;
font-family: system-ui, sans-serif;
font-size: .95rem;
font-weight: 600;
cursor: pointer;
user-select: none;
transition: background .2s, transform .2s;
}
.btn-danger {
background: rgba(255, 107, 107, .12);
color: #ff6b6b;
border: 2px solid #ff6b6b;
}
.btn-danger:hover {
background: #ff6b6b;
color: #fff;
}
.confirm-panel {
display: none;
align-items: center;
gap: .75rem;
background: #141415;
border: 1px solid #2a2a2d;
border-radius: 12px;
padding: .75rem 1.25rem;
animation: confirm-slide-in .2s ease;
}
.confirm-panel p {
font-size: .9rem;
color: #88888f;
white-space: nowrap;
}
.btn-yes {
padding: .45rem 1rem;
background: #ff6b6b;
color: #fff;
border: none;
border-radius: 8px;
font-family: system-ui, sans-serif;
font-size: .875rem;
font-weight: 600;
cursor: pointer;
transition: opacity .15s;
}
.btn-yes:hover {
opacity: .85;
}
.btn-cancel {
display: inline-block;
padding: .45rem 1rem;
background: #1c1c1e;
color: #88888f;
border-radius: 8px;
font-size: .875rem;
font-weight: 500;
cursor: pointer;
user-select: none;
transition: color .15s;
}
.btn-cancel:hover {
color: #f0f0f0;
}
/* Checking the box swaps the trigger out for the confirm panel */
.confirm-input:checked ~ .confirm-panel {
display: flex;
}
.confirm-input:checked ~ .btn-trigger {
opacity: .4;
pointer-events: none;
}
@keyframes confirm-slide-in {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
Other ways to build it
The keyboard reachable version
The only change from the version above is how the checkbox is hidden. display: none takes it out of the tab order and strands the whole component; a one pixel box at opacity: 0 keeps it focusable while making it invisible. The ring is then drawn on the label with :focus-visible, because the element actually holding focus has nothing to draw on. Tab to the control below and press space.
HTML
<div class="cb-a11y">
<input type="checkbox" id="a11y" class="cb-hidden-input">
<label for="a11y" class="btn-trigger btn-danger">Delete project</label>
<div class="cb-a11y-panel">
<p>Delete this project?</p>
<button class="btn-yes" type="button">Yes, delete</button>
<label for="a11y" class="btn-cancel">Cancel</label>
</div>
</div>
CSS
/* uses @keyframes confirm-slide-in from the main example above; the rules here are
what changes, not the whole file */
/* rendered, so it keeps its place in the tab order,
and invisible, so it does not show up in the layout */
.cb-hidden-input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
/* the input holding focus is invisible, so the label wears the ring */
.cb-hidden-input:focus-visible + .btn-trigger {
outline: 2px solid #b8ff57;
outline-offset: 3px;
}
.cb-a11y-panel {
display: none;
align-items: center;
gap: .75rem;
background: #141415;
border: 1px solid #2a2a2d;
border-radius: 12px;
padding: .75rem 1.25rem;
animation: confirm-slide-in .2s ease;
}
.cb-hidden-input:checked ~ .cb-a11y-panel {
display: flex;
}
.cb-hidden-input:checked ~ .btn-trigger {
opacity: .4;
pointer-events: none;
}
Swap in place, without the layout shift
Revealing a panel below the trigger pushes everything under it down, which is jarring in a table row or a settings list. Stacking both states in one grid cell with grid-area: 1 / 1 makes the wrapper as wide and as tall as the larger of the two, so the swap happens inside a box that never changes size. This version also drops display for visibility, which can be transitioned, so the two states cross fade instead of appearing outright.
HTML
<div class="cb-swap">
<input type="checkbox" id="revoke" class="cb-hidden-input">
<label for="revoke" class="cb-swap-trigger">Revoke API key</label>
<div class="cb-swap-panel">
<p>Revoke this key?</p>
<button type="button">Revoke</button>
<label for="revoke">Keep it</label>
</div>
</div>
CSS
.cb-swap {
display: grid;
justify-items: center;
align-items: center;
}
/* both states share one cell, so the box is sized by the larger
of the two and nothing below it moves on the swap */
.cb-swap-trigger,
.cb-swap-panel {
grid-area: 1 / 1;
}
/* visibility can be transitioned; display cannot */
.cb-swap-trigger {
transition: opacity .18s, visibility .18s;
}
.cb-swap-panel {
display: flex;
align-items: center;
gap: .75rem;
background: #141415;
border: 1px solid #2a2a2d;
border-radius: 12px;
padding: .75rem 1.25rem;
opacity: 0;
visibility: hidden;
transition: opacity .18s, visibility .18s;
}
.cb-hidden-input:checked ~ .cb-swap-panel {
opacity: 1;
visibility: visible;
}
.cb-hidden-input:checked ~ .cb-swap-trigger {
opacity: 0;
visibility: hidden;
}
How it works
A hidden checkbox stores the "are you sure?" state. The danger button is styled as a <label> that toggles the checkbox. When checked, CSS shows the confirmation panel using input:checked ~ .confirm-panel { display: flex; } and dims the original button with pointer-events: none. The Cancel label unchecks the box. No JavaScript is involved in the reveal, only the checkbox pattern used as a two-state toggle.
The checkbox is the state store. Because CSS has no variables that a click can write to, the only way to remember that someone pressed a control is to put a form element in the markup and read its state with :checked. The input sits first inside the wrapper, the panel comes after it, and ~ links them: .confirm-input:checked ~ .confirm-panel. The general sibling combinator only looks forward and only within the same parent, so the order in the HTML is load bearing. Move the input below the panel and the rule silently matches nothing.
Both controls that change state are labels, not buttons. A <label for> toggles its checkbox on click no matter where it sits in the document, which is what lets Cancel, buried inside the panel, turn the box back off. The Yes button is the only real <button> here, and it does not touch the checkbox at all, which is correct: it is meant to submit or trigger something rather than to change the display.
pointer-events: none on the dimmed trigger is doing more than fading it. Without it the label is still live under the panel and clicking it would uncheck the box, closing the confirmation the reader had just opened. Worth knowing about that property: it only blocks pointers. The element is still focusable and can still be activated from the keyboard, so it is a visual guard and not an actual disable.
The panel is revealed by switching display from none to flex, and display cannot be transitioned in the usual way, which is why the entrance uses animation rather than transition. An element that has just gone from display: none to rendered is treated as newly created, so its animation runs from the start every time the box is checked. A transition in the same place would have nothing to interpolate from and would do nothing at all. The second variant below sidesteps this by using visibility instead, which is transitionable.
There is a real defect in the version at the top of this page, and it is worth naming rather than copying: .confirm-input { display: none; } takes the checkbox out of the tab order entirely. Nothing about this component is reachable from a keyboard. The fix is to keep the input rendered but visually hidden, at one pixel with opacity: 0, so it still receives focus, and to draw the focus ring on the label with :focus-visible. The first variant below is the corrected version.
CSS properties used
:checked- Matches a checkbox or radio in its on state. This is the only selector in CSS that reads a click the user made earlier.
~- The general sibling combinator. Matches later siblings under the same parent, which is why the input has to appear before everything it controls.
display- Swapped from
nonetoflexto reveal the panel. Not transitionable, so the entrance has to come from an animation rather than a transition. pointer-eventsnonestops the dimmed trigger from swallowing clicks meant for the panel. It does not disable the element for keyboard users.:focus-visible- Draws the keyboard focus ring on the label, since the input carrying focus is visually hidden. Without it the component looks unfocused while it holds focus.
visibility- The transitionable alternative to
display. Used in the in-place swap below so both states can cross fade without a layout jump.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
CSS3 selectors | 4 | 3.5 | 3.2 | 12 |
CSS transitions | 4 | 5 | 5.1 | 12 |
CSS animation | 4 | 5 | 5.1 | 12 |
CSS grid | 57 | 52 | 10.1 | 16 |
pointer-events | 4 | 3.6 | 4 | 12 |
Nothing here needs a fallback. :checked and the sibling combinator are as old as CSS3 selector support, and the grid used by the in-place variant has been shipping since 2017. :focus-visible is the newest piece: Can I Use puts it at Chrome 86, Safari 15.4 and Edge 86, and credits Firefox from version 4 because of the :-moz-focusring selector it had long before the standard name existed. A browser that does not know :focus-visible drops the rule and shows no ring, so pair it with a plain :focus rule if your floor is old.
Accessibility notes
The keyboard problem is the one to fix first. display: none on the checkbox removes it from the tab order, and since the visible trigger is a label rather than a button there is nothing else to focus. Keep the input in the layout at one pixel with opacity: 0 instead, and put the ring on the label with :focus-visible. Space toggles a focused checkbox, so the component then works exactly as a keyboard user expects.
Assistive technology will announce the trigger as a checkbox, because that is what it is. For a two-stage reveal that is defensible, since the control genuinely toggles a state rather than performing an action. Do not paper over it with role="button", which would promise activation semantics the element does not have. If the announcement matters more than the zero JavaScript constraint, a real button with a script is the honest answer.
The confirmation panel appears without moving focus into it, so a screen reader user who checks the box hears nothing new. Wrapping the panel in role="alertdialog" will not help, because focus has not moved and CSS cannot move it. What CSS can do is make sure the panel comes immediately after the trigger in the DOM, so the next tab press lands on Yes. Order the markup accordingly.
What you can build with it
- Account and data deletion. The canonical case. A single click should never remove something that cannot be restored, and a second deliberate click costs almost nothing.
- Revoking credentials. API keys, sessions and access tokens, where the consequence is invisible until something breaks later.
- Leaving unsaved work. A discard control that opens a short confirmation in place rather than firing a full modal dialog for one question.
- Bulk operations. Anything applied to a selection, where the count in the confirmation copy is the part the reader actually needs to see.
- Unsubscribe and cancel flows. A second step that gives the reader a moment to change their mind without dropping them onto a separate page.
Mistakes worth avoiding
- Hiding the checkbox with
display: noneorvisibility: hidden. Both remove it from the tab order, and the component becomes unreachable by keyboard with nothing to indicate anything is wrong. - Putting the input after the panel in the markup.
~only matches forward, so the rule fails to match, the panel never appears, and no error is reported anywhere. - Leaving
pointer-eventsoff the dimmed trigger. It stays clickable behind the panel, and a stray click unchecks the box and closes the confirmation the reader just opened. - Reaching for
transitionon the panel'sdisplayproperty. It does nothing. Use ananimation, which restarts each time the element is rendered, or switch tovisibilityand transition that. - Expecting the Yes button to work. CSS reveals the confirmation and stops there. The destructive action still needs a form submission or a script, and the reveal being JavaScript free does not make the whole component JavaScript free.
Frequently asked questions
Does a CSS confirm button work without JavaScript?
:checked shows the panel, and a second label cancels. The action being confirmed does not: deleting a record still needs a form submission or a script. Treat this as a CSS interaction pattern wrapped around an ordinary control.Why is my confirm panel not showing?
~ matches only later siblings inside the same parent, so the checkbox has to come before the panel and both have to sit under the same wrapper. Moving the panel into a nested element breaks the match as surely as moving the input.Can I use a radio instead of a checkbox?
How do I make the confirm button keyboard accessible?
display: none. Position it absolutely at one pixel with opacity: 0 so it stays in the tab order, then style the focus ring on the label using .input:focus-visible + label. Space then toggles it the way a keyboard user expects.