CSS Off-Canvas Navigation

A slide-in navigation panel using a checkbox and CSS translate.

Published May 19, 2026 Intermediate 7 min read

Off-canvas navigation parks a menu outside the viewport and slides it in when it is needed. In CSS the whole thing is a checkbox holding the open state, a transform: translateX() on the panel, and a backdrop that is a second <label> bound to the same checkbox, which is what makes clicking outside close it.

The panel is moved with a transform rather than by animating left, because a transform runs on the compositor and does not force the browser to lay out the page on every frame. Two more versions follow: a drawer from the right that pushes the page content across instead of covering it, and the hamburger to cross animation that usually goes with the trigger.

Open the panel

HTML

<div class="offcanvas-frame">
  <input class="offcanvas-check" type="checkbox" id="oc1">
  <label class="offcanvas-open" for="oc1">☰ Open Nav</label>
  <label class="offcanvas-overlay" for="oc1"></label>
  <div class="offcanvas-panel">
    <label class="offcanvas-close" for="oc1">✕ Close</label>
    <nav>
      <a href="#">Home</a>
      <a href="#">About</a>
      <a href="#">Work</a>
      <a href="#">Contact</a>
    </nav>
  </div>
</div>

CSS

/* The frame is what the panel slides inside of.
   For a whole page, drop the frame and switch the
   panel and overlay to position: fixed. */
.offcanvas-frame {
  position: relative;
  overflow: hidden;
  width: 260px;
  min-height: 260px;
  padding: 1.5rem;
  background: #f5f5f8;
  border-radius: 10px;
}

.offcanvas-check {
  display: none;
}

.offcanvas-open {
  display: inline-flex;
  align-items: center;
  gap: .5rem;
  padding: .55rem 1rem;
  background: #b8ff57;
  color: #0c0c0d;
  border-radius: 8px;
  cursor: pointer;
  font-size: .9rem;
  user-select: none;
  position: relative;
  z-index: 2;
}

.offcanvas-panel {
  position: absolute;
  top: 0;
  left: 0;
  width: 220px;
  height: 100%;
  background: #141415;
  transform: translateX(-100%);
  transition: transform .3s ease;
  z-index: 10;
  padding: 1.5rem 0;
}

.offcanvas-panel nav a {
  display: block;
  padding: .65rem 1.25rem;
  color: #f0f0f0;
  font-size: .9rem;
  text-decoration: none;
  transition: background .15s;
}

.offcanvas-panel nav a:hover {
  background: rgba(255, 255, 255, .08);
}

.offcanvas-close {
  display: block;
  padding: .65rem 1.25rem;
  color: #88888f;
  font-size: .85rem;
  cursor: pointer;
  margin-bottom: .5rem;
  border-bottom: 1px solid rgba(255, 255, 255, .1);
}

.offcanvas-overlay {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, .45);
  opacity: 0;
  pointer-events: none;
  transition: opacity .3s;
  z-index: 9;
}

/* Checked state slides the panel in and fades the backdrop up */
.offcanvas-check:checked ~ .offcanvas-panel {
  transform: translateX(0);
}

.offcanvas-check:checked ~ .offcanvas-overlay {
  opacity: 1;
  pointer-events: auto;
}

Other ways to build it

A drawer from the right that pushes the content

Two transforms with the same duration and easing, running at once: the drawer comes in from translateX(100%) while the content block moves the other way by the drawer's width. There is no backdrop here, because nothing is being covered, so the close control has to live inside the drawer. This arrangement suits a filter or settings panel, where watching the page shift is part of the feedback. The closed drawer carries visibility: hidden so Tab cannot reach its links.

24 results

The content slides left by exactly the drawer's width, so nothing ends up hidden behind it. Both transforms share a duration and an easing curve, which is what makes the two moves read as one.

HTML

<div class="oc-push">
  <input class="ocp-check" type="checkbox" id="push">
  <div class="ocp-shift">
    <div class="ocp-bar">
      <span class="ocp-count">24 results</span>
      <label class="ocp-open" for="push">Filters</label>
    </div>
    <p>The content slides left by exactly the drawer's width, so nothing ends up hidden behind it. Both transforms share a duration and an easing curve, which is what makes the two moves read as one.</p>
  </div>
  <div class="ocp-panel">
    <label class="ocp-close" for="push">Close</label>
    <a href="#">In stock only</a>
    <a href="#">Price low to high</a>
    <a href="#">Newest first</a>
  </div>
</div>

CSS

.ocp-panel {
  position: absolute;
  top: 0;
  right: 0;
  width: 190px;
  height: 100%;
  transform: translateX(100%);
  /* visibility flips at the END of the slide, because the
     transition delays it to the end of the duration */
  visibility: hidden;
  transition: transform .3s ease, visibility .3s;
}

.ocp-shift {
  transition: transform .3s ease;
}

/* same duration, same easing, so the two read as one move */
.ocp-check:checked ~ .ocp-shift {
  transform: translateX(-190px);
}

.ocp-check:checked ~ .ocp-panel {
  transform: translateX(0);
  visibility: visible;
}

@media (prefers-reduced-motion: reduce) {
  .ocp-panel,
  .ocp-shift { transition: none; }
}

Hamburger that becomes a cross

Three absolutely positioned bars inside a fixed size label. Checking the box rotates the outer two by 45 degrees in opposite directions and moves them onto the center line, while the middle bar fades out. The rotation and the translation are one transform, and the order matters: translate first, then rotate, or the bar pivots around the wrong point and the cross comes out lopsided. The checkbox is hidden with opacity rather than display: none, so the button is still reachable with Tab and Space.

HTML

<input class="ocb-check" type="checkbox" id="menu">
<label class="ocb-burger" for="menu" aria-label="Menu">
  <span></span><span></span><span></span>
</label>

CSS

.ocb-burger {
  position: relative;
  display: block;
  width: 24px;
  height: 16px;
  cursor: pointer;
}

.ocb-burger span {
  position: absolute;
  left: 0;
  width: 100%;
  height: 2px;
  border-radius: 2px;
  background: #f0f0f0;
  transition: transform .3s ease, opacity .2s;
}

.ocb-burger span:nth-child(1) { top: 0; }
.ocb-burger span:nth-child(2) { top: 7px; }
.ocb-burger span:nth-child(3) { top: 14px; }

/* translate first, then rotate: reversing the order
   pivots the bar around the wrong point */
.ocb-check:checked ~ .ocb-burger span:nth-child(1) {
  transform: translateY(7px) rotate(45deg);
}

.ocb-check:checked ~ .ocb-burger span:nth-child(2) {
  opacity: 0;
}

.ocb-check:checked ~ .ocb-burger span:nth-child(3) {
  transform: translateY(-7px) rotate(-45deg);
}

How it works

A checkbox acts as the open/closed state. The nav panel starts at translateX(-100%) and slides to translateX(0) when :checked. A second <label> element over the overlay acts as the backdrop close button. The overlay fades in simultaneously using the same sibling combinator on the checkbox.

The demos here are boxed inside a frame with position: relative and overflow: hidden, which keeps the panel inside the example rather than covering this article. On a real page you drop the frame and switch the panel and the backdrop to position: fixed, so they sit against the viewport and stay put while the page behind them scrolls. Everything else is identical.

The backdrop is doing more work than it appears to. It is a <label> pointing at the same checkbox as the open button, stretched across the whole frame with inset: 0, sitting under the panel and over the page. Clicking anywhere outside the panel therefore hits the label and unchecks the box. This is the only way CSS can implement an outside click, and it is why the backdrop carries pointer-events: none while closed. Without that it would swallow every click on the page even at zero opacity.

A panel translated off the edge is still in the document. Its links stay focusable, so pressing Tab on a closed menu walks focus into a menu nobody can see, and the browser scrolls sideways trying to bring the focused link into view. visibility: hidden while closed fixes it, and pairing it with a transition means visibility flips at the end of the slide out rather than at the start, so the animation still plays in full.

Locking the page behind an open panel used to need script. body:has(#nav-toggle:checked) { overflow: hidden } does it in one rule now, because :has() lets an ancestor react to the state of a descendant. That raises the browser floor for that one behavior, and the failure mode is mild: on an older browser the page behind the panel still scrolls.

The push variant moves the content as well as the panel. Both transforms have the same duration and easing, which is what makes them read as one motion rather than two things happening at once. It suits a narrow drawer holding filters or settings. For a full height navigation menu, covering the page with a backdrop is the clearer signal that the reader has left the page they were on.

CSS properties used

transform
translateX(-100%) parks the panel just off the edge and translateX(0) brings it in. A transform does not trigger layout, unlike animating left.
position
absolute inside a framed demo, fixed on a real page so the panel is measured against the viewport rather than an ancestor.
inset
0 stretches the backdrop over the whole frame in one declaration instead of four offsets.
pointer-events
none on the closed backdrop, so an invisible full screen element does not intercept every click on the page.
visibility
hidden on the closed panel, which is what keeps Tab from walking focus into a menu that is off-screen.
:checked
The open state. Both the panel and the backdrop are reached from it with the ~ combinator.

Browser support

FeatureChromeFirefoxSafariEdge
transform43.53.112
transition455.112
:checked and ~43.53.212
position: fixed423.112
:has()10512115.4105

Figures come from Can I Use for 2D transforms, CSS transitions, CSS3 selectors, position: fixed, and the :has() relational pseudo-class. Only the scroll lock needs :has(), and without it the page behind an open panel simply keeps scrolling. On iOS, a fixed panel and an on-screen keyboard interact badly enough that 100vh is worth replacing with 100dvh, since the older unit measures the viewport as if the browser chrome were hidden.

Accessibility notes

Keyboard focus is where this pattern is weakest. A closed panel that is only moved out of view still holds focusable links, so Tab walks into it invisibly. Hide it with visibility: hidden while closed. The harder half is the opposite case: once the panel is open, focus should be trapped inside it until it closes, and CSS has no way to do that. A keyboard reader can Tab straight out of an open menu and into the page behind it.

Escape should close the panel and cannot. There is no key handling in CSS, so the backdrop label is the only outside dismissal available. Make sure a visible close control exists inside the panel too, because on a narrow screen the backdrop may be a sliver a few pixels wide.

The hamburger button needs an accessible name. Three <span> elements have no text, so the <label> wrapping them takes an aria-label, and the checkbox behind it must be hidden with position: absolute; opacity: 0 rather than display: none so it stays focusable and Space still toggles it. As with every CSS-only disclosure, aria-expanded cannot be kept in sync from a stylesheet.

The slide should stop under prefers-reduced-motion: reduce. A full width panel traveling across the viewport is a large movement, and the panel arriving in place immediately is just as usable.

What you can build with it

  • Mobile site navigation. The original case. A full menu that would take three screens as a list sits off-canvas until it is asked for.
  • Filter drawers on listing pages. The push variant fits here, because the reader wants to see the results move as the filters take up space.
  • Cart and checkout panels. A right hand drawer summarising a basket without leaving the product page.
  • Admin sidebars. A collapsible section tree that is permanently visible on a wide screen and off-canvas below a breakpoint, controlled by a media query on the same markup.
  • Settings and help panels. Secondary content that would otherwise need its own page, paired with an accordion inside the panel for sub-sections.

Mistakes worth avoiding

  • Animating left instead of transform. Every frame recalculates layout for the whole page rather than compositing a moved layer, and the slide stutters on a mid-range phone.
  • Leaving the backdrop without pointer-events: none while closed. A transparent element covering the viewport intercepts every click on the page, and the cause is invisible in a screenshot.
  • Moving the closed panel off-screen without visibility: hidden. Tab walks focus into the hidden menu and the browser scrolls sideways chasing it, which looks like the layout has broken.
  • Using position: absolute on a real page instead of fixed. The panel is then positioned against the nearest positioned ancestor, so it scrolls away with the content instead of staying against the viewport.
  • Putting a transform on an ancestor of a fixed panel. A transformed ancestor becomes the containing block for fixed descendants, so the panel stops behaving as fixed and starts scrolling with the page.

Frequently asked questions

How do I make an off-canvas menu with CSS only?
Hide a checkbox, point a <label> at it for the open button, and give the panel transform: translateX(-100%). Under .check:checked ~ .panel set translateX(0). Add a second full screen <label> bound to the same checkbox as the backdrop, so clicking outside closes it.
Why does my off-canvas panel scroll away with the page?
It is position: absolute rather than fixed, or an ancestor has a transform, filter or perspective on it. A transformed ancestor becomes the containing block for fixed positioned descendants, which quietly turns a fixed panel back into an absolute one.
How do I stop the page behind an open panel from scrolling?
body:has(#nav:checked) { overflow: hidden }. The relational pseudo-class lets the body react to a checkbox anywhere inside it. Browsers without :has() ignore the rule, and the page behind keeps scrolling, which is a degradation rather than a break.
Can a CSS off-canvas menu trap focus?
No. Trapping focus means moving it when it reaches the edge of the panel, and CSS cannot move focus. The panel can be kept out of the tab order while closed with visibility: hidden, but once open, a keyboard reader can Tab out of it into the page behind.
Should the menu slide over the content or push it?
Cover it with a backdrop when the menu is the primary navigation, because dimming the page says the reader has stepped away from it. Push the content when the panel is a companion, such as filters, where seeing the results shift is part of the point.