CSS Show and Hide Content

Toggle visibility of content using a checkbox and the ~ combinator.

Published May 19, 2026 Beginner 8 min read

Showing and hiding content in CSS comes down to storing a boolean somewhere the stylesheet can read. A checkbox is that somewhere. It has two states the browser tracks for you, :checked matches one of them, and the ~ combinator carries that match sideways to any element that appears later in the same parent. The checkbox itself is hidden, and a <label> pointing at it becomes the button.

This page has three versions. The base one flips a panel between display: none and display: block. The second collapses a long block of text to a few lines and animates it back out, with the button label changing between states. The third uses :target instead of a checkbox, which puts the open state in the URL so the panel can be linked to directly.

Toggle a panel with one click

This content was hidden until you clicked the button, with no JavaScript needed.

HTML

<input class="toggle-check" type="checkbox" id="shc1">
<label class="toggle-btn" for="shc1">Show content</label>
<div class="toggle-content">
  This content was hidden until you clicked the button. No JavaScript needed.
</div>

CSS

.toggle-check {
  display: none;
}

.toggle-btn {
  display: inline-block;
  padding: .6rem 1.25rem;
  background: #b8ff57;
  color: #0c0c0d;
  border-radius: 8px;
  cursor: pointer;
  font-size: .95rem;
  user-select: none;
}

.toggle-content {
  display: none;
  margin-top: 1rem;
  padding: 1rem 1.25rem;
  background: rgba(184, 255, 87, 0.1);
  border-left: 4px solid #b8ff57;
  border-radius: 0 8px 8px 0;
  font-size: .95rem;
}

.toggle-check:checked ~ .toggle-content {
  display: block;
}

Other ways to build it

Read more, with an animated collapse

Instead of removing the panel, this version keeps it in the layout at max-height: 84px and raises the ceiling when the checkbox is checked. A gradient sits over the bottom of the collapsed text so the cut reads as intentional rather than as a rendering fault, and it fades out along with the expansion. The button label is generated content, so content swaps between the two words without a second element to hide and show.

The general sibling combinator was standardised in Selectors Level 3, alongside the structural pseudo-classes most people reach for far more often. It matches any element that shares a parent with the subject and appears after it in source order, at any distance.

That last part is what makes the checkbox technique work. The panel does not have to be adjacent to the input, only later, so a label, a heading and a wrapper can all sit between them without breaking the match.

What it cannot do is look backwards or downwards. There is no previous sibling combinator, and a descendant of a sibling is not itself a sibling. Both of those limits are why :has() changed how these patterns get written.

HTML

<div class="rm-card">
  <input class="rm-check" type="checkbox" id="more">
  <div class="rm-box">
    <div class="rm-text"> ...long copy... </div>
    <div class="rm-fade"></div>
  </div>
  <label class="rm-btn" for="more"></label>
</div>

CSS

/* focusable, but not visible */
.rm-check {
  position: absolute;
  opacity: 0;
  width: 1px;
  height: 1px;
}

.rm-text {
  max-height: 84px;
  overflow: hidden;
  transition: max-height .35s ease;
}

/* a ceiling, not a measurement: it has to clear
   the tallest copy this block will ever hold */
.rm-check:checked ~ .rm-box .rm-text {
  max-height: 400px;
}

.rm-fade {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 46px;
  pointer-events: none;
  background: linear-gradient(to bottom, transparent, #141415);
  transition: opacity .3s;
}

.rm-check:checked ~ .rm-box .rm-fade {
  opacity: 0;
}

/* the label text is generated, so one element covers both states */
.rm-btn::after { content: "Read more"; }
.rm-check:checked ~ .rm-btn::after { content: "Read less"; }

.rm-check:focus-visible ~ .rm-btn {
  outline: 2px solid #b8ff57;
  outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
  .rm-text { transition: none; }
}

Linkable panel with :target

Here the trigger is an ordinary link to a fragment and the panel is hidden until :target matches it. The open state lives in the URL, so the panel can be linked to from anywhere and the back button closes it. Closing is a second link pointing at a fragment nothing uses, which leaves the document with no target element at all. Following either link scrolls the page to the panel, which is the behavior to weigh before choosing this over a checkbox.

Show the note

This panel is visible because the address bar ends in #show-hide-content-note. Reload the page with that fragment in place and it opens again, which no checkbox can do.

Hide it again

HTML

<a class="tg-open" href="#note">Show the note</a>
<div class="tg-panel" id="note">
  <p>Visible because the URL ends in #note.</p>
  <!-- a fragment nothing matches clears :target -->
  <a class="tg-close" href="#shut">Hide it again</a>
</div>

CSS

.tg-panel {
  display: none;
  /* keeps a fixed header off the panel after the jump */
  scroll-margin-top: 5rem;
}

.tg-panel:target {
  display: block;
}

How it works

A hidden checkbox acts as state. The <label> becomes the clickable button. The ~ general sibling combinator lets CSS target elements that follow the checkbox in the DOM, so .toggle-check:checked ~ .toggle-content shows the panel when checked.

The combinator is the constraint that shapes the markup. ~ only looks forward, and only at siblings. The panel has to come after the checkbox and share the same parent, which is why the input usually sits at the very top of the block with the label and the panel below it. + would work too, but only if the panel is the immediately next element, which breaks the moment you put the label between them. Getting this wrong produces a toggle that clicks and does nothing at all, with no error anywhere to explain why.

:has() lifts that restriction. body:has(#toggle:checked) .panel reaches anywhere in the document from a checkbox anywhere else, because the match is evaluated on the ancestor rather than traveling forward through siblings. This site's own theme switch works exactly that way. The cost is a floor on browser support, so the sibling version is still the safer default for a component that ships to everyone.

display: none is the bluntest way to hide the panel and it has real consequences. The content is removed from the layout, from the accessibility tree, and from find in page. Nothing about it can be transitioned, because there is no intermediate state between rendered and not rendered. When the reveal should animate, hide the panel with max-height: 0 and overflow: hidden, or with a grid row that transitions from 0fr to 1fr the way the accordion does.

The max-height approach carries a trap that only shows up later. The open value is a guess, and it has to be larger than the tallest content that will ever appear. Guess too low and the panel silently clips text once someone edits the copy. Guess too high and the transition spends most of its duration animating empty space, so the panel appears to open slowly then snap. The grid version avoids both because 1fr resolves to whatever the content actually measures.

:target is the third option and the only one that survives a page reload. The state lives in the URL fragment, so a link straight to the open panel works, the back button closes it, and a reader can share the open state. The tradeoff is that following the link scrolls the page to the panel, and there is only ever one target element in a document, so two :target panels cannot be open at once.

CSS properties used

display
none and block are the on and off states. Cheapest way to hide, but it removes the content from layout and from the accessibility tree.
:checked
Matches the hidden input when it is on. This is the state the rest of the selector is built around.
~
The general sibling combinator. Matches any later sibling, which is why the panel has to sit after the input in the same parent.
max-height
An animatable stand-in for height when the reveal has to be gradual. The open value is a ceiling you pick, not a measurement.
overflow
hidden on the collapsing element clips the content while it is shorter than its contents, which is what makes a max-height collapse look clean.
:target
Matches the element whose id is in the URL fragment. Turns the open state into something linkable and undoable with the back button.

Browser support

FeatureChromeFirefoxSafariEdge
:checked and ~43.53.212
:target43.53.212
transition455.112
:has()10512115.4105
gradients103615.412

:checked, ~ and :target are all CSS3 selectors, so the figures above are the CSS3 selectors row on Can I Use. Nothing on this page needs a fallback except the :has() variation, which is the one thing here with a modern floor. The fade over the collapsed text uses a linear gradient ending in transparent, and very old WebKit interpolated that through opaque black, producing a gray band instead of a fade. Ending the gradient in an explicit rgba() of the background color sidesteps it.

Accessibility notes

A checkbox hidden with display: none is not in the tab order, which makes the toggle unreachable without a mouse. Hide it with position: absolute; opacity: 0; width: 1px; height: 1px instead. The input keeps focus, Space still toggles it, and the visible focus ring goes on the label through :focus-visible. The base demo on this page uses display: none because it mirrors the snippet most people copy, and that is exactly the line to change first in production.

A screen reader announces the control as a checkbox, because that is what it is. It will not say expanded or collapsed, and nothing in CSS can make it. When the panel holds content a reader has to find, <details> and <summary> report the right role for free. The checkbox is the right choice when what you need is a two state switch rather than a disclosure.

The :target version behaves differently again. The trigger is a real link, so it is focusable and announced as a link, and activating it moves the page to the panel. That movement is jarring if the panel is far from the trigger, and it is the main reason to keep the two close together. Give the panel scroll-margin-top if a fixed header would otherwise cover it after the jump.

Anything that animates open should stop under prefers-reduced-motion: reduce. The panel still appears, it just arrives at full height immediately instead of sliding.

What you can build with it

  • Read more on long copy. Collapse a product description or a bio to a few lines with a fade over the cut, then let the reader expand the rest in place instead of loading a second page.
  • Advanced options in a form. Keep the four fields most people fill in visible and fold the other twelve behind a toggle.
  • Spoilers and answers. Hide a puzzle solution or a plot detail until the reader asks for it. The :target version lets you link straight to the revealed state.
  • Filter panels on a listing page. A checkbox opens the filter drawer on narrow screens while the same markup stays permanently visible on wide ones, controlled by a media query.
  • Inline help text. A question mark next to a field reveals a paragraph of explanation without a tooltip that vanishes when the pointer leaves it.

Mistakes worth avoiding

  • Putting the panel before the checkbox, or in a different parent. ~ only matches later siblings, so the selector never matches and the toggle appears completely dead. Nothing warns you: the CSS is valid, it just selects nothing.
  • Hiding the input with display: none and calling it done. The toggle stops being keyboard operable, which is a straight accessibility failure that no amount of styling fixes.
  • Transitioning display. There is no midpoint between none and block, so the panel jumps regardless of the duration you set. Collapse max-height or a grid row instead.
  • Picking a max-height open value that is only slightly larger than today's content. The first person to add a paragraph gets a silently clipped panel, and it will not be obvious that the stylesheet is the cause.
  • Using :target for two panels that should open together. A document has one target at a time, so opening the second closes the first, whether or not that is what you meant.

Frequently asked questions

How do I show and hide a div with CSS only?
Put a hidden checkbox before the div, give it an id, point a <label> at that id, and write .check:checked ~ .panel { display: block } with the panel hidden by default. The label becomes the button, the checkbox holds the state, and no script is involved.
Why does my checkbox toggle do nothing?
Almost always because the panel is not a later sibling of the input. The ~ combinator looks forward through siblings only, so a panel nested inside another wrapper, or placed above the checkbox, is out of reach. Either move the markup or switch to :has() on a shared ancestor.
Can I animate a CSS show hide toggle?
Not with display. Collapse the panel with max-height: 0 and overflow: hidden and transition the max height, or make the panel a one row grid and transition grid-template-rows from 0fr to 1fr. The grid version does not need a guessed height.
What is the difference between the checkbox method and :target?
The checkbox keeps the state inside the page, so a reload closes the panel and the URL never changes. :target puts the state in the URL fragment, which makes the open panel linkable and closable with the back button, at the cost of the page scrolling to it when the link is followed.
Does hidden content still get indexed?
Content hidden with display: none is still in the HTML that is served, so a crawler can read it. It is not visible to a reader without interaction, so treat it as supporting detail rather than the place to put the text you most want found.