GitHub

Overlay

The Overlay component is the foundation for building overlay interactions like Modal, Drawer, Popover, and more. It provides all the essential features needed for accessible and user-friendly overlay experiences.

Import

import { Overlay } from 'frontile';

Usage

Basic Overlay

The most basic usage of the Overlay component.

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Overlay } from 'frontile';
import { Button } from 'frontile';

export default class BasicOverlay extends Component {
  @tracked isOpen = false;

  @action toggle() {
    this.isOpen = !this.isOpen;
  }

  <template>
    <div class='flex flex-col gap-4'>
      <Button @onPress={{this.toggle}}>
        Open Overlay
      </Button>

      <Overlay @isOpen={{this.isOpen}} @onClose={{this.toggle}}>
        <div class='bg-content1 p-8 rounded-lg shadow-lg max-w-md'>
          <h2 class='text-lg font-semibold mb-4'>Overlay Content</h2>
          <p class='mb-4'>This is the content inside the overlay. You can put
            any content here.</p>
          <div class='flex gap-2'>
            <Button @intent='primary' @onPress={{this.toggle}}>
              Close
            </Button>
            <Button>
              Another Action
            </Button>
          </div>
        </div>
      </Overlay>
    </div>
  </template>
}

Overlay with Different Backdrop Types

Control the appearance of the backdrop behind the overlay.

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Overlay } from 'frontile';
import { Button } from 'frontile';

export default class BackdropTypes extends Component {
  @tracked fadedOpen = false;
  @tracked blurredOpen = false;
  @tracked noneOpen = false;

  @action toggleFaded() {
    this.fadedOpen = !this.fadedOpen;
  }

  @action toggleBlurred() {
    this.blurredOpen = !this.blurredOpen;
  }

  @action toggleNone() {
    this.noneOpen = !this.noneOpen;
  }

  <template>
    <div class='flex flex-col gap-4'>
      <div class='flex gap-2'>
        <Button @onPress={{this.toggleFaded}}>
          Faded Backdrop
        </Button>
        <Button @onPress={{this.toggleBlurred}}>
          Blurred Backdrop
        </Button>
        <Button @onPress={{this.toggleNone}}>
          No Backdrop
        </Button>
      </div>

      <Overlay
        @isOpen={{this.fadedOpen}}
        @onClose={{this.toggleFaded}}
        @backdrop='faded'
      >
        <div class='bg-content1 p-6 rounded-lg shadow-lg'>
          <h3 class='font-semibold mb-2'>Faded Backdrop</h3>
          <p class='mb-4'>Standard semi-transparent backdrop</p>
          <Button @onPress={{this.toggleFaded}}>Close</Button>
        </div>
      </Overlay>

      <Overlay
        @isOpen={{this.blurredOpen}}
        @onClose={{this.toggleBlurred}}
        @backdrop='blur'
      >
        <div class='bg-content1 p-6 rounded-lg shadow-lg'>
          <h3 class='font-semibold mb-2'>Blurred Backdrop</h3>
          <p class='mb-4'>Backdrop with blur effect</p>
          <Button @onPress={{this.toggleBlurred}}>Close</Button>
        </div>
      </Overlay>

      <Overlay
        @isOpen={{this.noneOpen}}
        @onClose={{this.toggleNone}}
        @backdrop='none'
      >
        <div class='bg-content1 p-6 rounded-lg shadow-lg border'>
          <h3 class='font-semibold mb-2'>No Backdrop</h3>
          <p class='mb-4'>Overlay without backdrop</p>
          <Button @onPress={{this.toggleNone}}>Close</Button>
        </div>
      </Overlay>
    </div>
  </template>
}

Render In Place

By default, overlays are rendered in a portal. You can render them in-place instead.

This container shows the difference between portal and in-place rendering.

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Overlay } from 'frontile';
import { Button } from 'frontile';

export default class RenderInPlace extends Component {
  @tracked portalOpen = false;
  @tracked inPlaceOpen = false;

  @action togglePortal() {
    this.portalOpen = !this.portalOpen;
  }

  @action toggleInPlace() {
    this.inPlaceOpen = !this.inPlaceOpen;
  }

  <template>
    <div class='flex flex-col gap-4'>
      <div class='flex gap-2'>
        <Button @onPress={{this.togglePortal}}>
          Portal Overlay (Default)
        </Button>
        <Button @onPress={{this.toggleInPlace}}>
          In-Place Overlay
        </Button>
      </div>

      <div
        class='relative border-2 border-dashed border-neutral-soft p-4 min-h-48'
      >
        <p class='text-sm text-neutral mb-4'>This container shows the difference
          between portal and in-place rendering.</p>

        <Overlay
          @isOpen={{this.inPlaceOpen}}
          @onClose={{this.toggleInPlace}}
          @renderInPlace={{true}}
        >
          <div
            class='absolute inset-4 bg-content1 p-4 rounded shadow-lg border'
          >
            <h3 class='font-semibold mb-2'>In-Place Overlay</h3>
            <p class='text-sm mb-4'>This overlay is rendered within its parent
              container.</p>
            <Button @size='sm' @onPress={{this.toggleInPlace}}>Close</Button>
          </div>
        </Overlay>
      </div>

      <Overlay @isOpen={{this.portalOpen}} @onClose={{this.togglePortal}}>
        <div class='bg-content1 p-6 rounded-lg shadow-lg'>
          <h3 class='font-semibold mb-2'>Portal Overlay</h3>
          <p class='mb-4'>This overlay is rendered in a portal (outside the
            normal DOM tree).</p>
          <Button @onPress={{this.togglePortal}}>Close</Button>
        </div>
      </Overlay>
    </div>
  </template>
}

Custom Close Behavior

Control when and how the overlay can be closed.

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Overlay } from 'frontile';
import { Button } from 'frontile';

export default class CustomCloseBehavior extends Component {
  @tracked normalOpen = false;
  @tracked noEscapeOpen = false;
  @tracked noOutsideClickOpen = false;
  @tracked confirmCloseOpen = false;

  @action toggleNormal() {
    this.normalOpen = !this.normalOpen;
  }

  @action toggleNoEscape() {
    this.noEscapeOpen = !this.noEscapeOpen;
  }

  @action toggleNoOutsideClick() {
    this.noOutsideClickOpen = !this.noOutsideClickOpen;
  }

  @action toggleConfirmClose() {
    this.confirmCloseOpen = !this.confirmCloseOpen;
  }

  @action handleConfirmClose() {
    if (confirm('Are you sure you want to close this overlay?')) {
      this.confirmCloseOpen = false;
    }
  }

  <template>
    <div class='flex flex-col gap-4'>
      <div class='grid grid-cols-2 gap-2'>
        <Button @onPress={{this.toggleNormal}}>
          Normal Overlay
        </Button>
        <Button @onPress={{this.toggleNoEscape}}>
          No Escape Key
        </Button>
        <Button @onPress={{this.toggleNoOutsideClick}}>
          No Outside Click
        </Button>
        <Button @onPress={{this.toggleConfirmClose}}>
          Confirm Close
        </Button>
      </div>

      <Overlay @isOpen={{this.normalOpen}} @onClose={{this.toggleNormal}}>
        <div class='bg-content1 p-6 rounded-lg shadow-lg'>
          <h3 class='font-semibold mb-2'>Normal Overlay</h3>
          <p class='mb-4'>Can be closed with Escape key, outside click, or
            button.</p>
          <Button @onPress={{this.toggleNormal}}>Close</Button>
        </div>
      </Overlay>

      <Overlay
        @isOpen={{this.noEscapeOpen}}
        @onClose={{this.toggleNoEscape}}
        @closeOnEscapeKey={{false}}
      >
        <div class='bg-content1 p-6 rounded-lg shadow-lg'>
          <h3 class='font-semibold mb-2'>No Escape Key</h3>
          <p class='mb-4'>Cannot be closed with Escape key. Try pressing Escape!</p>
          <Button @onPress={{this.toggleNoEscape}}>Close</Button>
        </div>
      </Overlay>

      <Overlay
        @isOpen={{this.noOutsideClickOpen}}
        @onClose={{this.toggleNoOutsideClick}}
        @closeOnOutsideClick={{false}}
      >
        <div class='bg-content1 p-6 rounded-lg shadow-lg'>
          <h3 class='font-semibold mb-2'>No Outside Click</h3>
          <p class='mb-4'>Cannot be closed by clicking outside. Try clicking the
            backdrop!</p>
          <Button @onPress={{this.toggleNoOutsideClick}}>Close</Button>
        </div>
      </Overlay>

      <Overlay
        @isOpen={{this.confirmCloseOpen}}
        @onClose={{this.handleConfirmClose}}
      >
        <div class='bg-content1 p-6 rounded-lg shadow-lg'>
          <h3 class='font-semibold mb-2'>Confirm Close</h3>
          <p class='mb-4'>Shows confirmation dialog before closing.</p>
          <Button @onPress={{this.handleConfirmClose}}>Close</Button>
        </div>
      </Overlay>
    </div>
  </template>
}

Focus Management

Demonstrate focus trapping and restoration features.

Open an overlay and use Tab/Shift+Tab to see focus behavior. Focus should return to the trigger button when closed.

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Overlay } from 'frontile';
import { Button } from 'frontile';
import { Input } from 'frontile';

export default class FocusManagement extends Component {
  @tracked focusTrapOpen = false;
  @tracked noFocusTrapOpen = false;

  @action toggleFocusTrap() {
    this.focusTrapOpen = !this.focusTrapOpen;
  }

  @action toggleNoFocusTrap() {
    this.noFocusTrapOpen = !this.noFocusTrapOpen;
  }

  <template>
    <div class='flex flex-col gap-4'>
      <div class='flex gap-2'>
        <Button @onPress={{this.toggleFocusTrap}} data-test-trigger>
          Focus Trap Enabled
        </Button>
        <Button @onPress={{this.toggleNoFocusTrap}}>
          Focus Trap Disabled
        </Button>
      </div>

      <p class='text-sm text-neutral'>
        Open an overlay and use Tab/Shift+Tab to see focus behavior. Focus
        should return to the trigger button when closed.
      </p>

      <Overlay @isOpen={{this.focusTrapOpen}} @onClose={{this.toggleFocusTrap}}>
        <div class='bg-content1 p-6 rounded-lg shadow-lg max-w-md'>
          <h3 class='font-semibold mb-4'>Focus Trap Enabled</h3>
          <p class='mb-4 text-sm'>Focus is trapped within this overlay. Try
            pressing Tab to cycle through focusable elements.</p>

          <div class='space-y-3'>
            <Input @label='First Input' />
            <Input @label='Second Input' />
            <div class='flex gap-2'>
              <Button @intent='primary' @onPress={{this.toggleFocusTrap}}>
                Close
              </Button>
              <Button>
                Another Button
              </Button>
            </div>
          </div>
        </div>
      </Overlay>

      <Overlay
        @isOpen={{this.noFocusTrapOpen}}
        @onClose={{this.toggleNoFocusTrap}}
        @disableFocusTrap={{true}}
      >
        <div class='bg-content1 p-6 rounded-lg shadow-lg max-w-md'>
          <h3 class='font-semibold mb-4'>Focus Trap Disabled</h3>
          <p class='mb-4 text-sm'>Focus is not trapped. You can Tab to elements
            outside the overlay.</p>

          <div class='space-y-3'>
            <Input @label='Input Field' />
            <Button @onPress={{this.toggleNoFocusTrap}}>
              Close
            </Button>
          </div>
        </div>
      </Overlay>
    </div>
  </template>
}

Animations and Transitions

Customize overlay animations and transition duration.

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Overlay } from 'frontile';
import { Button } from 'frontile';

export default class AnimationsAndTransitions extends Component {
  @tracked fastOpen = false;
  @tracked slowOpen = false;
  @tracked noAnimationOpen = false;

  @action toggleFast() {
    this.fastOpen = !this.fastOpen;
  }

  @action toggleSlow() {
    this.slowOpen = !this.slowOpen;
  }

  @action toggleNoAnimation() {
    this.noAnimationOpen = !this.noAnimationOpen;
  }

  <template>
    <div class='flex flex-col gap-4'>
      <div class='flex gap-2'>
        <Button @onPress={{this.toggleFast}}>
          Fast Animation (100ms)
        </Button>
        <Button @onPress={{this.toggleSlow}}>
          Slow Animation (800ms)
        </Button>
        <Button @onPress={{this.toggleNoAnimation}}>
          No Animation
        </Button>
      </div>

      <Overlay
        @isOpen={{this.fastOpen}}
        @onClose={{this.toggleFast}}
        @transitionDuration={{100}}
      >
        <div class='bg-content1 p-6 rounded-lg shadow-lg'>
          <h3 class='font-semibold mb-2'>Fast Animation</h3>
          <p class='mb-4'>This overlay opens and closes quickly (100ms).</p>
          <Button @onPress={{this.toggleFast}}>Close</Button>
        </div>
      </Overlay>

      <Overlay
        @isOpen={{this.slowOpen}}
        @onClose={{this.toggleSlow}}
        @transitionDuration={{800}}
      >
        <div class='bg-content1 p-6 rounded-lg shadow-lg'>
          <h3 class='font-semibold mb-2'>Slow Animation</h3>
          <p class='mb-4'>This overlay has a longer transition duration (800ms).</p>
          <Button @onPress={{this.toggleSlow}}>Close</Button>
        </div>
      </Overlay>

      <Overlay
        @isOpen={{this.noAnimationOpen}}
        @onClose={{this.toggleNoAnimation}}
        @disableTransitions={{true}}
      >
        <div class='bg-content1 p-6 rounded-lg shadow-lg'>
          <h3 class='font-semibold mb-2'>No Animation</h3>
          <p class='mb-4'>This overlay appears instantly without transitions.</p>
          <Button @onPress={{this.toggleNoAnimation}}>Close</Button>
        </div>
      </Overlay>
    </div>
  </template>
}

Outside Click vs Overlay Element Click

The Overlay component has two different click-to-close mechanisms that work together:

  • @closeOnOutsideClick (default: true): Closes when clicking the backdrop/outside area
  • @closeOnOverlayElementClick (default: true): Closes when clicking the overlay element itself

Default Behavior: Try clicking on the overlay element (the outer container) vs the inner content card.

Disabled: Only the backdrop (outside area) will close the overlay.

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Overlay } from 'frontile';
import { Button } from 'frontile';

export default class OverlayElementClick extends Component {
  @tracked defaultOpen = false;
  @tracked disabledOpen = false;

  @action toggleDefault() {
    this.defaultOpen = !this.defaultOpen;
  }

  @action toggleDisabled() {
    this.disabledOpen = !this.disabledOpen;
  }

  <template>
    <div class='flex flex-col gap-4'>
      <div class='flex gap-2'>
        <Button @onPress={{this.toggleDefault}}>
          Default Behavior
        </Button>
        <Button @onPress={{this.toggleDisabled}}>
          Overlay Element Click Disabled
        </Button>
      </div>

      <div class='text-sm space-y-2'>
        <p><strong>Default Behavior:</strong>
          Try clicking on the overlay element (the outer container) vs the inner
          content card.</p>
        <p><strong>Disabled:</strong>
          Only the backdrop (outside area) will close the overlay.</p>
      </div>

      <Overlay @isOpen={{this.defaultOpen}} @onClose={{this.toggleDefault}}>
        <div class='bg-content1 p-6 rounded-lg shadow-lg max-w-md'>
          <h3 class='font-semibold mb-2'>Default Behavior</h3>
          <p class='mb-4 text-sm'>
            <code>@closeOnOverlayElementClick={{true}}</code>
            (default)
          </p>
          <p class='mb-4'>Clicking anywhere on the overlay element will close
            it, but clicking this inner content card won't.</p>
          <Button @onPress={{this.toggleDefault}}>Close</Button>
        </div>
      </Overlay>

      <Overlay
        @isOpen={{this.disabledOpen}}
        @onClose={{this.toggleDisabled}}
        @closeOnOverlayElementClick={{false}}
      >
        <div class='bg-content1 p-6 rounded-lg shadow-lg max-w-md'>
          <h3 class='font-semibold mb-2'>Overlay Element Click Disabled</h3>
          <p class='mb-4 text-sm'>
            <code>@closeOnOverlayElementClick={{false}}</code>
          </p>
          <p class='mb-4'>Only clicking the backdrop (outside area) will not
            close this overlay because the overlay content element is on top of
            backdrop.</p>
          <Button @onPress={{this.toggleDisabled}}>Close</Button>
        </div>
      </Overlay>
    </div>
  </template>
}

Why is @closeOnOverlayElementClick true by default?

This option is set to true by default to make "outside click" functionality work intuitively. Most overlay content is wrapped with an inner element (like a card or dialog), which prevents accidental closure when clicking on the actual content. The overlay element itself acts as part of the "outside" area, allowing users to click anywhere around the content to dismiss the overlay.

Using Power Select inside an Overlay

FormSelect uses Power Select, which defaults to @renderInPlace={{false}} and so inserts its dropdown outside the overlay. The focus trap then treats the dropdown as outside the overlay and blocks access to it, including its search input.

Pass @renderInPlace={{true}} on the FormSelect so the dropdown stays inside the trap:

<FormSelect @renderInPlace={{true}} />

Disabling the overlay's focus trap with @disableFocusTrap={{true}} also works, but it removes the trap for everything else in the overlay too — prefer the first option.

Accessibility

Overlay is the primitive under Modal, Drawer and Popover, and supplies their shared focus and keyboard behavior. It sets tabindex="0" on the content element but no role — a semantic role is the consuming component's job, which is why Modal and Drawer add role="dialog" themselves. If you build directly on Overlay, give it a role and an accessible name.

Behavior Detail
Focus on open Moves into the overlay; ember-focus-trap keeps it there
Focus trap Disable with @disableFocusTrap={{true}}, or tune via @focusTrapOptions
Focus on close Returns to the previously focused element, unless @preventFocusRestore
Escape Closes, unless @closeOnEscapeKey={{false}}
Backdrop click Closes, unless @closeOnOutsideClick={{false}}
Body scroll Blocked while open, unless @blockScroll={{false}}

The overlay needs at least one focusable element inside it. A focus trap with nothing to focus leaves the keyboard stranded, and nothing warns you at runtime.

Frontile does not set aria-modal, aria-labelledby or aria-describedby at this level.

API

Overlay

Element: <span class="hljs-title class_">HTMLDivElement</span>

Arguments

Name Type Default Description
isOpen * boolean - Whether it is open or not
backdrop enum - How the area behind the overlay is rendered: none omits the backdrop entirely, transparent keeps it clickable but invisible, faded dims the page, and blur blurs it.
backdropTransition Object - Transition classes for the backdrop, overriding the defaults used when it fades in and out.
blockScroll boolean true
class string - Custom class name for the content element, merged with the default ones using Tailwind Merge.
closeOnEscapeKey boolean true Whether to close when the escape key is pressed
closeOnOutsideClick boolean true Whether to close when the area outside (the backdrop) is clicked
closeOnOverlayElementClick boolean true Whether to close when the overlay element is clicked, used for modal and drawer components. This is set to true by default to allow "outside click" functionality to work properly. Most overlay content is wrapped with an inner element, preventing accidental closure.
customContentModifier ModifierLike<{ Element: HTMLElement; }> - An extra modifier applied to the content element, for behavior the overlay doesn't provide itself.
didClose function - A function that will be called when closing is finished executing, this includes waiting for animations/transitions to finish.
disableFlexContent boolean false Opt out of the flex layout applied to the content element, for overlays that need to lay their content out themselves.
disableFocusTrap boolean false Whether the focus trap is disabled or not
disableTransitions boolean false Disable css transitions
focusTrapOptions any { clickOutsideDeactivates: true, allowOutsideClick: true } Focus trap options
onClose function - A function that will be called when closed
onOpen function - A function that will be called when opened
preventAutoFocus boolean false When focusTrap is disabled, by default Oberlay will be auto focused. This option prevents that.
preventFocusRestore boolean false
renderInPlace boolean false Whether to render in place or in the specified/default destination
target enum -

The target where to render the portal. There are 3 options: 1) Element object, 2) element id, 3) portal target name.

For element id, string must be prefixed with #. If no value is passed in, we will render to the closest unnamed portal target, parent portal or document.body.

transition Object {name:'overlay-transition--fade'} Transition options
transitionDuration number 200 Duration of the animation

Blocks

Name Type Default Description
default * Array -
Released under MIT License - Created by Josemar Luedke