Skip to content

Off-Canvas Pattern — Complete Guide

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you will learn about Off. We cover key concepts, practical examples, and best practices to help you master this topic.

The off-canvas pattern places navigation or panels off-screen and slides them into view using transitions, JavaScript toggle, focus management, and sometimes swipe gestures.

What You'll Learn

  • CSS transitions for off-canvas sliding
  • JavaScript toggle with aria attributes
  • Focus management for Accessibility
  • Swipe gesture support for mobile
  • Multiple off-canvas panels
  • Performance and animation considerations

Why It Matters

  • Mobile screens are too small for persistent navigation
  • Off-canvas saves space while keeping content accessible
  • Users understand the pattern from native mobile apps
  • Proper implementation maintains accessibility

Real-World Use

  • A mobile navigation menu slides from the left
  • A shopping cart panel slides from the right
  • A settings panel slides from the bottom
  • A filter panel overlays the main content
flowchart LR
  A[Off-Canvas Menu] --> B[Off-screen position]
  B --> C[User clicks hamburger]
  C --> D[CSS transition slide in]
  D --> E[Focus moves to first link]
  E --> F[Escape closes menu]
  F --> G[Focus returns to hamburger]

Off-Canvas Implementation

Code Example: Basic Off-Canvas Navigation

<nav class="offcanvas-nav" id="mobile-nav" aria-label="Main navigation">
    <button class="close-btn" id="close-nav" aria-label="Close navigation">&times;</button>
    <ul>
        <li><a href="/">Home</a></li>
        <li><a href="/about">About</a></li>
        <li><a href="/services">Services</a></li>
        <li><a href="/contact">Contact</a></li>
    </ul>
</nav>
<button class="hamburger" id="open-nav" aria-label="Open navigation" aria-expanded="false">
    <span class="hamburger-line"></span>
    <span class="hamburger-line"></span>
    <span class="hamburger-line"></span>
</button>
<main id="main-content">
    <h1>Page Content</h1>
</main>
<div class="overlay" id="nav-overlay"></div>

<style>
body {
    margin: 0;
    overflow-x: hidden;
}
.offcanvas-nav {
    position: fixed;
    top: 0;
    left: 0;
    width: 280px;
    height: 100%;
    background: #fff;
    box-shadow: 2px 0 8px rgba(0,0,0,0.1);
    transform: translateX(-100%);
    transition: transform 0.3s ease;
    z-index: 1000;
    padding: 1rem;
}
.offcanvas-nav.open {
    transform: translateX(0);
}
.overlay {
    position: fixed;
    inset: 0;
    background: rgba(0,0,0,0.4);
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.3s ease;
    z-index: 999;
}
.overlay.visible {
    opacity: 1;
    pointer-events: auto;
}
.hamburger {
    position: fixed;
    top: 1rem;
    left: 1rem;
    z-index: 1001;
    background: none;
    border: none;
    cursor: pointer;
    min-height: 44px;
    min-width: 44px;
}
.hamburger-line {
    display: block;
    width: 24px;
    height: 2px;
    background: #333;
    margin: 5px 0;
    transition: transform 0.3s ease;
}
.hamburger.active .hamburger-line:nth-child(1) {
    transform: rotate(45deg) translate(5px, 5px);
}
.hamburger.active .hamburger-line:nth-child(2) {
    opacity: 0;
}
.hamburger.active .hamburger-line:nth-child(3) {
    transform: rotate(-45deg) translate(5px, -5px);
}
.close-btn {
    position: absolute;
    top: 0.5rem;
    right: 0.5rem;
    min-width: 44px;
    min-height: 44px;
    font-size: 1.5rem;
}
@media (min-width: 768px) {
    .offcanvas-nav {
        transform: translateX(0);
        position: static;
        width: auto;
        box-shadow: none;
    }
    .hamburger, .overlay, .close-btn {
        display: none;
    }
}
</style>

<script>
const nav = document.getElementById('mobile-nav');
const openBtn = document.getElementById('open-nav');
const closeBtn = document.getElementById('close-nav');
const overlay = document.getElementById('nav-overlay');
const main = document.getElementById('main-content');

function openNav() {
    nav.classList.add('open');
    overlay.classList.add('visible');
    openBtn.setAttribute('aria-expanded', 'true');
    openBtn.classList.add('active');
    document.body.style.overflow = 'hidden';
    closeBtn.focus();
}

function closeNav() {
    nav.classList.remove('open');
    overlay.classList.remove('visible');
    openBtn.setAttribute('aria-expanded', 'false');
    openBtn.classList.remove('active');
    document.body.style.overflow = '';
    openBtn.focus();
}

openBtn.addEventListener('click', openNav);
closeBtn.addEventListener('click', closeNav);
overlay.addEventListener('click', closeNav);
document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape' && nav.classList.contains('open')) {
        closeNav();
    }
});

// Trap focus inside nav when open
nav.addEventListener('keydown', (e) => {
    if (e.key === 'Tab') {
        const focusable = nav.querySelectorAll('a, button, [tabindex]:not([tabindex="-1"])');
        const first = focusable[0];
        const last = focusable[focusable.length - 1];
        if (e.shiftKey && document.activeElement === first) {
            e.preventDefault();
            last.focus();
        } else if (!e.shiftKey && document.activeElement === last) {
            e.preventDefault();
            first.focus();
        }
    }
});
</script>

Expected output: On mobile, a hamburger button is visible. Clicking it slides the navigation from the left and shows an overlay. Focus moves to the close button. Escape or clicking the overlay closes the menu and returns focus to the hamburger. On desktop (768px+), the navigation is always visible.

Code Example: Right-Side Cart Panel

<button class="cart-toggle" id="cart-toggle" aria-label="Open shopping cart" aria-expanded="false">
    Cart (3)
</button>
<aside class="cart-panel" id="cart-panel" role="dialog" aria-modal="true" aria-label="Shopping cart">
    <div class="cart-header">
        <h2>Your Cart</h2>
        <button class="cart-close" id="cart-close" aria-label="Close cart">&times;</button>
    </div>
    <div class="cart-items">
        <div class="cart-item">Item 1 - $19.99</div>
        <div class="cart-item">Item 2 - $29.99</div>
        <div class="cart-item">Item 3 - $9.99</div>
    </div>
    <div class="cart-footer">
        <p>Total: $59.97</p>
        <button class="checkout-btn">Checkout</button>
    </div>
</aside>
<div class="cart-overlay" id="cart-overlay"></div>

<style>
.cart-panel {
    position: fixed;
    top: 0;
    right: -320px;
    width: 320px;
    height: 100%;
    background: #fff;
    box-shadow: -2px 0 8px rgba(0,0,0,0.1);
    transition: right 0.3s ease;
    z-index: 1000;
    display: flex;
    flex-direction: column;
}
.cart-panel.open {
    right: 0;
}
.cart-overlay {
    position: fixed;
    inset: 0;
    background: rgba(0,0,0,0.4);
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.3s ease;
    z-index: 999;
}
.cart-overlay.visible {
    opacity: 1;
    pointer-events: auto;
}
.cart-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 1rem;
    border-bottom: 1px solid #eee;
}
.cart-items {
    flex: 1;
    overflow-y: auto;
    padding: 1rem;
}
.cart-footer {
    padding: 1rem;
    border-top: 1px solid #eee;
}
</style>

Expected output: Clicking the cart button slides a panel from the right with the cart contents, overlay behind. The same focus management, escape, and overlay-click patterns apply.

Code Example: Bottom Sheet Pattern

<div class="bottom-sheet" id="bottom-sheet" role="dialog" aria-modal="true" aria-label="Filter options">
    <div class="sheet-handle"></div>
    <div class="sheet-content">
        <h3>Filter Options</h3>
        <label><input type="checkbox"> Category A</label>
        <label><input type="checkbox"> Category B</label>
        <label><input type="checkbox"> Category C</label>
        <button class="apply-filters">Apply</button>
    </div>
</div>

<style>
.bottom-sheet {
    position: fixed;
    bottom: 0;
    left: 0;
    right: 0;
    background: #fff;
    border-radius: 16px 16px 0 0;
    transform: translateY(100%);
    transition: transform 0.3s ease;
    z-index: 1000;
    max-height: 70vh;
    overflow-y: auto;
}
.bottom-sheet.open {
    transform: translateY(0);
}
.sheet-handle {
    width: 40px;
    height: 4px;
    background: #ccc;
    border-radius: 2px;
    margin: 0.5rem auto;
}
.sheet-content {
    padding: 1rem;
}
</style>

Expected output: A bottom sheet slides up from the bottom of the viewport with filter options. Common in mobile apps for share sheets, action sheets, and filter panels.

Common Mistakes

  1. No focus management — Opening an off-canvas panel without moving focus traps keyboard users. Focus must move to the panel.
  2. No Escape key support — Users expect Escape to close overlays and off-canvas panels.
  3. No overlay for focus trapping — Without an overlay, users can tab outside the panel. The overlay prevents interaction with background content.
  4. No body scroll prevention — Background content scrolls while the panel is open. Set overflow: hidden on body.
  5. Transition too slow or too fast — 0.3s is standard. Longer than 0.5s feels sluggish. Shorter than 0.15s feels jarring.
  6. z-index conflicts — Off-canvas panels need high z-index. Ensure the overlay and panel have the correct stacking order.
  7. Not testing with reduced motion — Users with vestibular disorders need reduced motion. Respect prefers-reduced-motion.

Practice Questions

  1. What is the minimum width for off-canvas navigation? 280px is standard. Mobile screens are at least 320px wide, leaving 40px for the page content behind the overlay.
  2. How do you handle focus when an off-canvas panel opens? Move focus to the first focusable element in the panel (usually the close button).
  3. What CSS property creates the slide animation? transform: translateX() with a CSS transition property.
  4. Why should you prevent body scrolling while off-canvas is open? So users do not accidentally scroll page content behind the open panel.

FAQ

Should off-canvas work with swipe gestures?

Yes, for mobile users. Use touchstart/touchend events to detect horizontal swipes. Libraries like hammer.js can help.

What is the difference between off-canvas and a modal?

Off-canvas slides from the side or bottom. A modal appears centered. Off-canvas is for navigation and panels. Modals are for alerts and confirmations.

Does off-canvas work on desktop?

Typically off-canvas is for mobile only. On desktop, navigation is usually visible. Use a media query to show the navigation normally on large screens.

What is a bottom sheet?

A panel that slides up from the bottom of the screen. Common in mobile apps for share, filter, and action menus.

Mini Project

Build a responsive site with three off-canvas elements: a left-side navigation menu, a right-side cart panel, and a bottom-sheet filter. Add swipe gesture support for the left navigation. Implement focus trapping and body scroll prevention for each. Add reduced motion support using prefers-reduced-motion media query. Test with keyboard (Tab, Enter, Escape) on mobile and desktop breakpoints. Add a skip link at the top of the page that bypasses all off-canvas elements.

What's Next

Continue with Lesson 22: Container Queries to learn component-level Responsive Design.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro