CSS Click to Reveal
A click-to-reveal panel driven by a hidden checkbox and a label, with versions that animate the open or blur the text in place for a spoiler.
- Level
- Beginner
- Published
- Updated
- Reading time
- 7 min
Try it
3 live demos
Reveal on click
Other ways to build it
Reveal that animates open
See the codeClick to clear the blur
Spoiler blur
See the codeBuild it
6 snippets to copy
CSS Click to Reveal
HTML
<input class="reveal-check" type="checkbox" id="cr1">
<label class="reveal-btn" for="cr1">
<svg class="reveal-icon icon-eye" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
<svg class="reveal-icon icon-lock" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
Reveal the answer
</label>
<div class="reveal-panel">
The answer to life, the universe, and everything is <strong>42</strong>. All without JavaScript.
</div>
CSS
.reveal-check {
display: none;
}
.reveal-btn {
display: inline-flex;
align-items: center;
gap: .5rem;
padding: .6rem 1.25rem;
background: #1c1c1e;
color: #f0f0f0;
border: 1px solid #2a2a2d;
border-radius: 8px;
font-size: .9rem;
cursor: pointer;
user-select: none;
transition: background .2s, color .2s, border-color .2s;
}
.reveal-icon { flex-shrink: 0; }
/* The lock only appears once the panel is open */
.icon-lock { display: none; }
.reveal-panel {
display: none;
margin-top: 1rem;
padding: 1.25rem;
background: rgba(184, 255, 87, .1);
border-radius: 10px;
color: #f0f0f0;
font-size: .95rem;
}
.reveal-check:checked ~ .reveal-btn {
background: #b8ff57;
color: #0c0c0d;
border-color: #b8ff57;
}
.reveal-check:checked ~ .reveal-btn .icon-eye { display: none; }
.reveal-check:checked ~ .reveal-btn .icon-lock { display: block; }
.reveal-check:checked ~ .reveal-panel {
display: block;
}
Reveal that animates open
Since display cannot be transitioned, the panel becomes a one row grid and the row itself animates from 0fr to 1fr. A wrapper inside carries overflow: hidden and min-height: 0, which stops the content spilling out while the row is still shorter than it is. Nothing here knows the panel's height in advance, so it opens to whatever the content measures. The checkbox is clipped to one pixel rather than set to display: none, so it keeps its place in the tab order.
HTML
<input type="checkbox" id="policy" class="reveal-anim-check">
<label for="policy" class="reveal-anim-btn">Show the shipping policy</label>
<div class="reveal-anim-panel">
<div class="reveal-anim-inner">
<div class="reveal-anim-body">Orders leave the warehouse within two business days.</div>
</div>
</div>
CSS
/* clipped, not display:none, so the input keeps its place in the tab
order. clip-path clips hit testing too, so this one pixel box cannot
sit on top of the label and swallow a click. */
.reveal-anim-check {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.reveal-anim-panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows .35s ease;
}
.reveal-anim-check:checked ~ .reveal-anim-panel {
grid-template-rows: 1fr;
}
/* both are needed: the row can shrink below the content only if the
child stops asking for its own minimum height */
.reveal-anim-inner {
overflow: hidden;
min-height: 0;
}
.reveal-anim-body {
margin-top: .75rem;
padding: 1rem 1.25rem;
background: rgba(184, 255, 87, .1);
border-radius: 10px;
}
/* the input is invisible, so the ring has to go on the label */
.reveal-anim-check:focus-visible + .reveal-anim-btn {
outline: 2px solid #b8ff57;
outline-offset: 2px;
}
Spoiler blur
Here the text never leaves the layout. A filter: blur() makes it unreadable and user-select: none stops anyone dragging across it to read the selection, then :checked clears both. The page height does not change when it opens, which is the reason to pick this over a panel that expands. What it does not do is hide anything: the words are in the accessibility tree and the browser find bar matches them.
HTML
<input type="checkbox" id="spoiler" class="reveal-spoiler-check">
<label for="spoiler" class="reveal-spoiler-text">The butler did it.</label>
CSS
.reveal-spoiler-check {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.reveal-spoiler-text {
display: block;
padding: 1rem 1.25rem;
background: #1c1c1e;
border: 1px solid #2a2a2d;
border-radius: 10px;
cursor: pointer;
filter: blur(6px);
/* without this, dragging across the blur reveals the text anyway */
user-select: none;
transition: filter .3s ease;
}
.reveal-spoiler-check:checked + .reveal-spoiler-text {
filter: none;
user-select: auto;
cursor: default;
}
.reveal-spoiler-check:focus-visible + .reveal-spoiler-text {
outline: 2px solid #b8ff57;
outline-offset: 2px;
}
Understand it
How it works and 6 properties
A CSS click to reveal panel keeps content hidden until the reader asks for it, and the only piece of state involved is a checkbox nobody sees. A <label> points at that checkbox with a for attribute, so clicking the label flips :checked, and a sibling selector turns that flip into a style change on the panel further down the page. Nothing here runs as script.
It fits quiz answers, spoilers in a review, and any block of small print that should not push the rest of the page down before anyone wants to read it. Two more versions follow. One animates the panel open instead of snapping it into place, and one leaves the text where it is and blurs it, which behaves very differently for a screen reader.
How it works
Identical in mechanism to Show/Hide Content, where a hidden checkbox acts as a boolean state variable. The <label> is styled as a button. This example adds a visual state change on the button itself when checked, using :checked ~ .reveal-btn to restyle the trigger.
Three elements sit side by side: the <input type="checkbox">, the <label> styled to look like a button, and the panel. They have to appear in that order, because the general sibling combinator ~ only looks forward from the element on its left. Move the panel above the input and .reveal-check:checked ~ .reveal-panel matches nothing at all, which is the single most common reason a checkbox reveal does nothing when clicked.
~ matches any later sibling rather than only the next one, which is why a single checkbox can drive both the button restyle and the panel. + would stop at the label sitting between them. The trigger repaints in the accent color under :checked ~ .reveal-btn, and two more rules swap which of the two inline SVG icons is displayed, an eye when the panel is closed and a padlock when it is open. Both icons are in the markup permanently and only their display value changes, so there is no second image to fetch.
The panel toggles between display: none and display: block, and display is not an animatable property. The value jumps at the halfway point of any transition you write, so the panel appears instantly no matter what duration you set. The animated variant below sidesteps this by making the panel a one row grid and transitioning grid-template-rows from 0fr to 1fr, which lets the browser interpolate the real height without anyone having to guess it in advance.
The older workaround was max-height, transitioning from 0 up to a number large enough to cover the tallest panel. It opens, but the timing is wrong whenever the content is shorter than the guess, because the transition keeps running against empty space long after the panel has finished moving.
Accessibility notes
The version at the top hides its checkbox with display: none, which is the shortest thing to write and also the reason the button cannot be reached with the Tab key. An element set to display: none is removed from the accessibility tree and from the tab order together. Both variants below hide the input with a clipped one pixel box and opacity: 0 instead, so it still takes focus, and a :focus-visible rule draws the ring on the label since the input itself is invisible.
A checkbox in the accessibility tree has the checkbox role and a checked or unchecked state, not the semantics of an expanded or collapsed disclosure. That makes sense for a spoiler toggle but not for a disclosure widget. When the thing you are building is really a disclosure, the <details> and <summary> pair covered in the accordion example carries the right semantics for free.
Content inside a display: none panel is not in the accessibility tree until the panel opens. The blur variant behaves the opposite way. Blurred text is still real text: it stays in the accessibility tree and the browser find bar locates it. Use blur for playfulness and display: none for anything a reader should not encounter by accident.
Mistakes worth avoiding
- Putting the panel before the input in the markup.
~looks forward only, so the selector matches nothing and clicking the button does nothing visible. - Hiding the checkbox with
display: none. The control disappears from the tab order, so the reveal becomes mouse only. A clipped one pixel box keeps it focusable. - Reusing the same
idfor more than one reveal on a page. Every label then points at the first checkbox, and one click opens the wrong panel. - Writing a
transitionon a rule that changesdisplay. The property does not interpolate, so the panel snaps open and the duration is ignored. - Treating a blur as if it were hidden. The text is in the accessibility tree and the browser find bar locates it.
Ship it
5 features in 4 browsers, 5 questions
Browser support
| Feature | ||||
|---|---|---|---|---|
:checked | 4 | 3.5 | 3.2 | 12 |
transition | 4 | 5 | 5.1 | 12 |
user-select | 4 | 2 | 3.1 | 12 |
grid | 57 | 52 | 10.1 | 16 |
filter | 18 | 35 | 6 | 79 |
The :checked row covers CSS3 selectors as a group on Can I Use, which is where both :checked and the ~ combinator are tracked. Grid support is listed for Grid Layout level 1, but the ability to transition between 0fr and 1fr arrived some years after grid itself and Can I Use carries no separate entry for it, so treat the animated variant as a modern browser enhancement and let older ones snap open instead.
What you can build with it
- Quiz and flashcard answers. The question stays visible, the answer waits behind a button, and the reader decides when to check themselves.
- Spoiler warnings. Plot details in a review, hidden until someone opts in. The blur variant reads as a spoiler at a glance without needing a label to explain it.
- Contact details. An email address or phone number that only renders once a person clicks, which cuts down on the simplest scrapers without hurting a real visitor.
- Long legal text. Terms, shipping policies, and returns collapsed to one line each so the page stays short for the people who never open them.
- Solution steps in a tutorial. Each step hidden behind its own toggle so a reader can attempt the problem before seeing the answer.
Frequently asked questions
How do you reveal content on click with CSS only?
<label for="cr1"> that points at it, and write .check:checked ~ .panel { display: block }. The label toggles the checkbox, :checked matches, and the sibling selector applies the new style. No JavaScript is involved at any point.Why is my checkbox reveal not working?
~ only matches elements that come after the one on its left, so the rule never applies. Move the input above everything it controls. The next most common cause is a duplicated id shared by two reveals on the same page.Can you animate a CSS reveal open?
display, which jumps rather than interpolating. Make the panel a single row grid and transition grid-template-rows from 0fr to 1fr with an overflow: hidden wrapper inside, as the first variant above does. That animates to the content's real height with no fixed value anywhere.Is the checkbox hack accessible?
display: none so it stays focusable, put the focus ring on the label, and accept that it exposes itself as a checkbox rather than as a disclosure.Should I use details and summary instead?
<summary> makes awkward.