p
  label(for='input-text') Please do not fill this in
  input(id='input-text')
p
  label(for='input-textarea') Or this one
  textarea(id='input-textarea' cols='20' rows='2') I certainly hope this textarea doesn't fall into focus
p
  a(href='https://www.bbc.co.uk/' target='_blank') Here's one of those links all the kids go on about

section(id='trap' aria-labelledby='trap-heading')
  h1(id='trap-heading') You are now trapped
  p
    a(href='https://www.bbc.co.uk/' target='_blank') Another link
  p Take a cookie, while you're here. Just one, though.
  p 🍪🍪🍪🍪🍪🍪🍪🍪🍪🍪🍪🍪🍪🍪🍪🍪🍪🍪🍪🍪🍪🍪🍪🍪🍪🍪🍪🍪🍪🍪
  p
    label(for='input-email') I don't care what your email address is
    input(id='input-email' type='email')
  p
    button(type='button' data-js='release-trap') Please release me, let me go
 
p
  button I guess you should submit this
View Compiled
// Just some basic boilerplate
:root {
  --dark: #111;
  --light: #eee;
  --warning: rgb(255 0 0 / 0.2);
}

body {
  background: var(--light);
  color: var(--dark);
}

@media (prefers-color-scheme: dark) {
  body {
    background: var(--dark);
    color: var(--light);
  }
  
  a {
    color: #99f;
    
    &:visited {
      color: #f9f;
    }
  }
}

// The focus trap element
section {
  background: repeating-linear-gradient(
    45deg,
    var(--warning),
    var(--warning) 1rem,
    transparent 1rem,
    transparent 2rem
  );
  border: double 1rem var(--warning);
  padding: 1rem;
}

body {
  font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
}

button, input, textarea {
  display: block;
  font-family: inherit;
}

*:focus {
  outline: solid 0.2em red;
}

// For the trap buttons
.visually-hidden {
  background: transparent;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
View Compiled
// Inspiration credit:
// https://stackoverflow.com/questions/7329141/how-do-i-get-the-previously-focused-element-in-javascript
const TrapFocus = (passedParent: HTMLElement): void => {
  
  // A string of selectors for elements which can receive focus
  const focusSelectors = 'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, [tabindex]:not([tabindex="-1"])';

  // This searches the supplied element for elements which can receive
  // focus then returns them in a nodelist.
  const getFocusable = (parent: HTMLElement): NodeList | undefined => {
    if (!parent) return undefined;
    return parent.querySelectorAll(focusSelectors);
  };
  
  const parent = passedParent;
  // All elements which can receive focus inside the element we care about
  const focusableElements = getFocusable(parent) as NodeList;
  // The first focusable element inside the parent
  const first = focusableElements[0] as HTMLElement;
  // The last focusable element inside the parent
  const last = focusableElements[focusableElements.length - 1] as HTMLElement;
  
  // This keeps track of if the trap buttons have been added to the DOM or not
  let buttonsAdded = false;
  
  const firstTrap: HTMLButtonElement = document.createElement('button');
  firstTrap.setAttribute('type', 'button');
  firstTrap.setAttribute('data-js', 'first');
  firstTrap.classList.add('visually-hidden');

  const lastTrap: HTMLButtonElement = document.createElement('button');
  lastTrap.setAttribute('type', 'button');
  lastTrap.setAttribute('data-js', 'last');
  lastTrap.classList.add('visually-hidden');
  
  const focusHandler = (event) => {
    const trigger = event.target;
    const triggerType = trigger.getAttribute('data-js');
    
    if (!buttonsAdded) {
      parent.prepend(firstTrap);
      parent.append(lastTrap);
      buttonsAdded = true;
    }

    // This is one of the hidden buttons which redirects focus
    if (triggerType && triggerType === 'first') {
      last.focus();
    }  
    else if (triggerType && triggerType === 'last') {
      first.focus();
    }  
  }

  const clickHandler = (event) => {
    const trigger = event.target;
    const triggerType = trigger.getAttribute('data-js');

    // The user wants to turn off the focus trap
    // (Note that this is not toggalable)
    if (triggerType && triggerType === 'release-trap') {
      document.body.removeEventListener('focusin', focusHandler, true);
      // The trap buttons can still fall into focus
      firstTrap.remove();
      lastTrap.remove();
      buttonsAdded = false;
    }  
  };

  document.body.addEventListener('focusin', focusHandler, true);
  // Used for the function which turns off the focus trap
  window.addEventListener('click', clickHandler);
};

// The element we want to trap the focus within
const trapElement = document.getElementById('trap');
TrapFocus(trapElement);
View Compiled

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/dialog-polyfill/0.5.6/dialog-polyfill.min.js