CSS Click to Reveal

Content that reveals on click using a checkbox as state.

Published May 19, 2026 Beginner 7 min read

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.

Reveal on click

The answer to life, the universe, and everything is 42. All without JavaScript.

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;
}

Other ways to build it

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.

Orders leave the warehouse within two business days. Tracking arrives by email once the label is scanned, which is usually the same evening.

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 still in the DOM, so a screen reader announces them and the browser find bar matches them.

Click to clear the blur

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;
}

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.

CSS properties used

:checked
Matches a checkbox or radio input that is currently on. Every state change on the page hangs off this one pseudo-class.
~
The general sibling combinator. Matches any element that comes after the one on its left, inside the same parent. Order in the markup is what makes it work.
display
none to block on the panel. Not animatable, so this pair gives an instant reveal rather than a transitioned one.
grid-template-rows
Transitioning 0fr to 1fr on a one row grid animates a panel open without a hard coded height. Used in the first variant.
user-select
none on the label stops a double click from selecting the button text instead of toggling the panel.
filter
blur() on the spoiler variant obscures text while leaving it in the layout and in the accessibility tree.

Browser support

FeatureChromeFirefoxSafariEdge
:checked43.53.212
transition455.112
user-select423.112
grid575210.116
filter1835679

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.

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 and label pair announces as a checkbox. A screen reader will say "checked" or "not checked", not "expanded" or "collapsed", which is a reasonable description of a spoiler toggle and a poor one 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, so nothing announces it until the panel opens. The blur variant behaves the opposite way. Blurred text is still real text, so a screen reader reads the spoiler out loud, the browser find bar locates it, and anyone can select and copy it. Use blur for playfulness and display: none for anything a reader genuinely should not encounter by accident.

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.

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 id for more than one reveal on a page. Every label then points at the first checkbox, and one click opens the wrong panel.
  • Writing a transition on a rule that changes display. 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 present in the DOM, announced by screen readers, and findable with the browser search bar.

Frequently asked questions

How do you reveal content on click with CSS only?
Put a hidden checkbox before the content, wrap the trigger in a <label for> 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?
In almost every case the panel sits before the input in the markup. ~ 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?
Not by transitioning 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?
It can be, but not in its shortest form. Hide the input with a clipped one pixel box rather than display: none so it stays focusable, put the focus ring on the label, and accept that it announces as a checkbox rather than as a disclosure.
Should I use details and summary instead?
For collapsible sections, usually yes, since the element carries expanded and collapsed semantics for free. The checkbox approach earns its place when the trigger and the revealed content are far apart in the markup, or when the trigger itself has to change appearance, which <summary> makes awkward.