Open demo

CSS code

HTML
<div class="fs">
  <header class="fs-bar">
    <a href="#" class="fs-logo">Northwind</a>
    <button type="button" class="fs-toggle" aria-expanded="false" aria-controls="fs-menu">Menu</button>
  </header>
  <nav class="fs-overlay" id="fs-menu" aria-label="Main">
    <ul class="fs-links">
      <li><a href="#" aria-current="page" style="--i:0">Work</a></li>
      <li><a href="#" style="--i:1">Studio</a></li>
      <li><a href="#" style="--i:2">Journal</a></li>
      <li><a href="#" style="--i:3">Contact</a></li>
    </ul>
    <ul class="fs-meta">
      <li><a href="#">Instagram</a></li>
      <li><a href="#">LinkedIn</a></li>
      <li><a href="mailto:[email protected]">[email protected]</a></li>
    </ul>
  </nav>
</div>
CSS
/* Fullscreen overlay menu. The Menu button (aria-expanded) wipes the overlay open with a clip-path circle that grows from
   the button, and the links stagger in (one --i custom property per item). While it is open, Tab stays inside the header
   and overlay, Esc closes it and returns focus to the button, and closed links are hidden from the tab order. */
.fs{--fs-bg:#101828;--fs-ink:#101828;--fs-hover:#a5b4fc;--fs-muted:#98a0b3;--fs-focus:#4f46e5;--fs-origin:calc(100% - 66px) 44px;position:relative}
.fs a{color:inherit;text-decoration:none}
.fs ul{margin:0;padding:0;list-style:none}
.fs-bar{position:relative;z-index:30;display:flex;align-items:center;justify-content:space-between;padding:26px 34px;color:var(--fs-ink);
  transition:color .2s}
.fs.open .fs-bar{color:#fff;--fs-focus:var(--fs-hover)}
.fs-logo{font-weight:800;font-size:1.1rem;letter-spacing:-.02em}
.fs-toggle{padding:10px 4px;border:0;border-bottom:2px solid currentColor;background:none;color:inherit;font:inherit;font-size:.8rem;
  font-weight:800;letter-spacing:.14em;text-transform:uppercase;cursor:pointer}
.fs a:focus-visible,.fs-toggle:focus-visible{outline:2px solid var(--fs-focus);outline-offset:3px}
.fs-overlay{--fs-focus:var(--fs-hover);position:fixed;inset:0;z-index:20;display:flex;flex-direction:column;justify-content:center;padding:0 34px;
  background:var(--fs-bg);color:#fff;visibility:hidden;clip-path:circle(0% at var(--fs-origin));
  transition:clip-path .55s cubic-bezier(.65,0,.35,1),visibility 0s linear .55s}
.fs.open .fs-overlay{visibility:visible;clip-path:circle(150% at var(--fs-origin));transition-delay:0s}
.fs-links a{display:block;font-size:clamp(2rem,7vw,3.6rem);font-weight:800;letter-spacing:-.035em;line-height:1.24;opacity:0;transform:translateY(18px);
  transition:opacity .4s ease calc(var(--i)*.06s + .18s),transform .4s ease calc(var(--i)*.06s + .18s),color .15s}
.fs.open .fs-links a{opacity:1;transform:none}
.fs-links a:hover,.fs-links a[aria-current]{color:var(--fs-hover)}
.fs .fs-meta{display:flex;flex-wrap:wrap;gap:12px 26px;margin-top:44px;font-size:.85rem;font-weight:600;color:var(--fs-muted);
  opacity:0;transition:opacity .4s ease .5s}
.fs.open .fs-meta{opacity:1}
.fs-meta a:hover{color:#fff}
@media (prefers-reduced-motion:reduce){
  .fs-overlay{transition-duration:.01s;transition-delay:0s}
  .fs-links a,.fs-meta{transition-duration:.01s;transition-delay:0s}
  .fs-bar{transition:none}}
JavaScript
// Toggle the overlay: the .open class drives every transition; aria-expanded reports the state.
// While open, Tab cycles through the header and overlay links only, and Esc closes and refocuses the button.
document.querySelectorAll('.fs').forEach(fs => {
  const btn = fs.querySelector('.fs-toggle');
  const set = open => {
    fs.classList.toggle('open', open);
    btn.setAttribute('aria-expanded', open);
  };
  btn.addEventListener('click', () => set(!fs.classList.contains('open')));
  fs.querySelector('.fs-overlay').addEventListener('click', e => { if (e.target.closest('a')) set(false); });
  fs.addEventListener('keydown', e => {
    if (!fs.classList.contains('open')) return;
    if (e.key === 'Escape') { set(false); btn.focus(); return; }
    if (e.key !== 'Tab') return;
    const items = [...fs.querySelectorAll('a[href], button')];
    const first = items[0], last = items.at(-1);
    if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
    else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
  });
});

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS, Bootstrap 5.3.8 JS bundle

HTML
<div class="fs">
  <header class="fs-bar">
    <a href="#" class="fs-logo">Northwind</a>
    <button type="button" class="fs-toggle" data-bs-toggle="offcanvas" data-bs-target="#fs-menu" aria-controls="fs-menu" aria-expanded="false">Menu</button>
  </header>
  <div class="offcanvas offcanvas-top fs-overlay" tabindex="-1" id="fs-menu" aria-label="Menu" data-bs-backdrop="false">
    <div class="fs-bar">
      <a href="#" class="fs-logo">Northwind</a>
      <button type="button" class="fs-toggle" data-bs-dismiss="offcanvas" aria-controls="fs-menu" aria-expanded="true">Menu</button>
    </div>
    <nav class="offcanvas-body" aria-label="Main">
      <ul class="list-unstyled m-0 fs-links">
        <li><a class="active" href="#" aria-current="page" style="--i:0">Work</a></li>
        <li><a href="#" style="--i:1">Studio</a></li>
        <li><a href="#" style="--i:2">Journal</a></li>
        <li><a href="#" style="--i:3">Contact</a></li>
      </ul>
      <ul class="list-unstyled d-flex flex-wrap mb-0 fs-meta">
        <li><a href="#">Instagram</a></li>
        <li><a href="#">LinkedIn</a></li>
        <li><a href="mailto:[email protected]">[email protected]</a></li>
      </ul>
    </nav>
  </div>
</div>
CSS
/* A full-height .offcanvas-top whose slide is replaced by a clip-path circle growing from the Menu button.
   The Offcanvas plugin traps focus, closes on Esc and hands focus back to the button. The overlay repeats the
   header (logo and a Menu button that closes it) in white, so the bar looks the same before and after it opens. */
.fs{--fs-bg:#101828;--fs-ink:#101828;--fs-hover:#a5b4fc;--fs-muted:#98a0b3;--fs-focus:#4f46e5;--fs-origin:calc(100% - 66px) 44px}
.fs a{color:inherit;text-decoration:none}
.fs-bar{display:flex;align-items:center;justify-content:space-between;padding:26px 34px;color:var(--fs-ink)}
.fs-logo{font-weight:800;font-size:1.1rem;letter-spacing:-.02em}
.fs-toggle{padding:10px 4px;border:0;border-bottom:2px solid currentColor;background:none;color:inherit;font:inherit;font-size:.8rem;line-height:normal;
  font-weight:800;letter-spacing:.14em;text-transform:uppercase}
.fs a:focus-visible,.fs-toggle:focus-visible{outline:2px solid var(--fs-focus);outline-offset:3px;box-shadow:none}
.fs-overlay.offcanvas{--bs-offcanvas-height:100%;--bs-offcanvas-bg:var(--fs-bg);--bs-offcanvas-color:#fff;--bs-offcanvas-border-width:0;--fs-focus:var(--fs-hover);
  transform:none;clip-path:circle(0% at var(--fs-origin));transition:clip-path .55s cubic-bezier(.65,0,.35,1)}
.fs-overlay.showing,.fs-overlay.show:not(.hiding){clip-path:circle(150% at var(--fs-origin))}
.fs-overlay .fs-bar{position:absolute;top:0;left:0;right:0;color:#fff}
.fs-overlay .offcanvas-body{display:flex;flex-direction:column;justify-content:center;padding:0 34px;overflow:visible}
.fs-links a{display:block;font-size:clamp(2rem,7vw,3.6rem);font-weight:800;letter-spacing:-.035em;line-height:1.24;opacity:0;transform:translateY(18px);
  transition:opacity .4s ease calc(var(--i)*.06s + .18s),transform .4s ease calc(var(--i)*.06s + .18s),color .15s}
.fs-overlay.showing .fs-links a,.fs-overlay.show:not(.hiding) .fs-links a{opacity:1;transform:none}
.fs-links a:hover,.fs-links a.active{color:var(--fs-hover)}
.fs-meta{column-gap:26px;row-gap:12px;margin-top:44px;font-size:.85rem;font-weight:600;color:var(--fs-muted);opacity:0;transition:opacity .4s ease .5s}
.fs-overlay.showing .fs-meta,.fs-overlay.show:not(.hiding) .fs-meta{opacity:1}
.fs-meta a:hover{color:#fff}
@media (prefers-reduced-motion:reduce){
  .fs-overlay.offcanvas{transition-duration:.01s}
  .fs-links a,.fs-meta{transition-duration:.01s;transition-delay:0s}}
JavaScript
/* Offcanvas does not manage aria-expanded on its trigger, so keep it in sync; a link click closes the overlay. */
document.querySelectorAll('.fs-overlay').forEach(overlay => {
  const toggle = document.querySelector(`[data-bs-target="#${overlay.id}"]`);
  overlay.addEventListener('show.bs.offcanvas', () => toggle.setAttribute('aria-expanded', 'true'));
  overlay.addEventListener('hidden.bs.offcanvas', () => toggle.setAttribute('aria-expanded', 'false'));
  overlay.addEventListener('click', e => { if (e.target.closest('.fs-links a, .fs-meta a')) bootstrap.Offcanvas.getInstance(overlay)?.hide(); });
});

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div data-fs class="group relative">
  <header class="relative z-30 flex items-center justify-between px-[34px] py-[26px] text-[#101828] transition-[color,background-color,border-color] duration-200 group-data-open:text-white motion-reduce:transition-none">
    <a href="#" class="text-[1.1rem] font-extrabold tracking-[-.02em] focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#4f46e5] group-data-open:focus-visible:outline-[#a5b4fc]">Northwind</a>
    <button type="button" class="cursor-pointer border-b-2 border-current px-1 py-2.5 text-[.8rem] font-extrabold tracking-[.14em] uppercase focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#4f46e5] group-data-open:focus-visible:outline-[#a5b4fc]" aria-expanded="false" aria-controls="fs-menu">Menu</button>
  </header>
  <nav class="invisible fixed inset-0 z-20 flex flex-col justify-center bg-[#101828] px-[34px] text-white [clip-path:circle(0%_at_calc(100%-66px)_44px)] transition-[clip-path,visibility] duration-[550ms,0s] delay-[0s,550ms] ease-[cubic-bezier(.65,0,.35,1)] group-data-open:visible group-data-open:[clip-path:circle(150%_at_calc(100%-66px)_44px)] group-data-open:delay-0 motion-reduce:duration-10 motion-reduce:delay-0" id="fs-menu" aria-label="Main">
    <ul>
      <li><a href="#" aria-current="page" class="block text-[clamp(2rem,7vw,3.6rem)] leading-[1.24] font-extrabold tracking-[-.035em] opacity-0 translate-y-[18px] transition-[opacity,translate,color] duration-[400ms,400ms,150ms] ease-in-out hover:text-[#a5b4fc] aria-[current=page]:text-[#a5b4fc] group-data-open:translate-y-0 group-data-open:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#a5b4fc] motion-reduce:duration-10 motion-reduce:delay-0 delay-[180ms]">Work</a></li>
      <li><a href="#" class="block text-[clamp(2rem,7vw,3.6rem)] leading-[1.24] font-extrabold tracking-[-.035em] opacity-0 translate-y-[18px] transition-[opacity,translate,color] duration-[400ms,400ms,150ms] ease-in-out hover:text-[#a5b4fc] aria-[current=page]:text-[#a5b4fc] group-data-open:translate-y-0 group-data-open:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#a5b4fc] motion-reduce:duration-10 motion-reduce:delay-0 delay-[240ms]">Studio</a></li>
      <li><a href="#" class="block text-[clamp(2rem,7vw,3.6rem)] leading-[1.24] font-extrabold tracking-[-.035em] opacity-0 translate-y-[18px] transition-[opacity,translate,color] duration-[400ms,400ms,150ms] ease-in-out hover:text-[#a5b4fc] aria-[current=page]:text-[#a5b4fc] group-data-open:translate-y-0 group-data-open:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#a5b4fc] motion-reduce:duration-10 motion-reduce:delay-0 delay-[300ms]">Journal</a></li>
      <li><a href="#" class="block text-[clamp(2rem,7vw,3.6rem)] leading-[1.24] font-extrabold tracking-[-.035em] opacity-0 translate-y-[18px] transition-[opacity,translate,color] duration-[400ms,400ms,150ms] ease-in-out hover:text-[#a5b4fc] aria-[current=page]:text-[#a5b4fc] group-data-open:translate-y-0 group-data-open:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#a5b4fc] motion-reduce:duration-10 motion-reduce:delay-0 delay-[360ms]">Contact</a></li>
    </ul>
    <ul class="mt-11 flex flex-wrap gap-x-[26px] gap-y-3 text-[.85rem] font-semibold text-[#98a0b3] opacity-0 transition-opacity delay-500 duration-[400ms] ease-in-out group-data-open:opacity-100 motion-reduce:duration-10 motion-reduce:delay-0">
      <li><a class="hover:text-white focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#a5b4fc]" href="#">Instagram</a></li>
      <li><a class="hover:text-white focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#a5b4fc]" href="#">LinkedIn</a></li>
      <li><a class="hover:text-white focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#a5b4fc]" href="mailto:[email protected]">[email protected]</a></li>
    </ul>
  </nav>
</div>
JavaScript
// Toggle the overlay: data-open on the wrapper drives every group-data-open: utility; aria-expanded reports the state.
// While open, Tab cycles through the header and overlay links only, and Esc closes and refocuses the button.
document.querySelectorAll('[data-fs]').forEach(fs => {
  const btn = fs.querySelector('button[aria-controls]');
  const set = open => {
    fs.toggleAttribute('data-open', open);
    btn.setAttribute('aria-expanded', open);
  };
  btn.addEventListener('click', () => set(!fs.hasAttribute('data-open')));
  fs.querySelector('nav').addEventListener('click', e => { if (e.target.closest('a')) set(false); });
  fs.addEventListener('keydown', e => {
    if (!fs.hasAttribute('data-open')) return;
    if (e.key === 'Escape') { set(false); btn.focus(); return; }
    if (e.key !== 'Tab') return;
    const items = [...fs.querySelectorAll('a[href], button')];
    const first = items[0], last = items.at(-1);
    if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
    else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
  });
});

About this set

A studio-style header whose Menu button opens a full-screen dark overlay with four large links (Work, Studio, Journal, Contact) and a row of social and email links underneath. The overlay does not slide in: it wipes open as a clip-path circle that grows from the Menu button, then the links rise and fade in one after another, each with its own delay from a –i custom property. The header text turns white while the menu is open, so the logo and button stay visible on top. Use it for portfolio, agency or editorial sites with a handful of top-level pages. The Menu button has aria-expanded and aria-controls. While the overlay is open, Tab cycles through the header and overlay links only, Esc closes it and returns focus to the button, and choosing a link closes it too. Once the wipe has closed, the overlay is visibility:hidden, so its links are out of the tab order. Under prefers-reduced-motion the wipe and the stagger become instant. The Bootstrap version runs the same wipe on a full-height .offcanvas, whose plugin supplies the focus trap and Esc handling.

What’s included

  • Clip-path circle wipe that grows from the Menu button
  • Staggered link entrance from one custom property per item
  • Focus held inside the open menu; Esc closes it and refocuses the button
  • Closed overlay removed from the tab order
  • Bootstrap version uses .offcanvas with the Offcanvas plugin

Accessibility

The toggle is a button with aria-expanded and aria-controls pointing at a nav labelled Main, and the current page link has aria-current="page". While the menu is open, keyboard focus stays in the header and overlay, and Esc closes it and returns focus to the button; closed links cannot be reached with Tab. Focus outlines switch to light indigo on the dark overlay, and motion is cut to near zero under prefers-reduced-motion.

Customise it

Change --fs-bg, --fs-hover, --fs-muted and --fs-focus on .fs, and --fs-origin to move the centre of the wipe if the button sits elsewhere. The link size is the clamp() on .fs-links a. In Tailwind edit the clip-path circle positions and the delay-[…] on each link.