Frontile

Overlay

The Overlay component is the foundation for Modal, Drawer, Popover, and similar overlays. It handles focus management, the backdrop, escape/outside-click dismissal, and scroll locking.

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='demo-stack demo-stack--wide items-center'>
      <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 @color='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='demo-stack demo-stack--wide items-center'>
      <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='demo-stack demo-stack--wide items-center'>
      <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='demo-stack demo-stack--wide items-center'>
      <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='demo-stack demo-stack--wide items-center'>
      <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 @color='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='demo-stack demo-stack--wide items-center'>
      <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>
}

Overlays that start open

An overlay whose @isOpen is already true the first time it renders — one that is deep-linked open, or that a page refresh restored — waits for the browser's first paint before appearing, so its animation plays against the page rather than starting before anything has been drawn.

Pass @animateOnMount={{false}} when an already-open overlay should simply be there, with no reveal — Modal and Drawer forward it too. An overlay opened later by interaction animates either way, and so does closing.

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 OverlaysThatStartOpen extends Component {
  @tracked showAnimated = false;
  @tracked showImmediate = false;

  @action mountAnimated() {
    this.showAnimated = true;
  }

  @action mountImmediate() {
    this.showImmediate = true;
  }

  @action closeAnimated() {
    this.showAnimated = false;
  }

  @action closeImmediate() {
    this.showImmediate = false;
  }

  <template>
    <div class='demo-stack demo-stack--wide items-center'>
      <div class='flex gap-2'>
        <Button @onPress={{this.mountAnimated}}>
          Render already open
        </Button>
        <Button @onPress={{this.mountImmediate}}>
          Render already open, no animation
        </Button>
      </div>

      {{#if this.showAnimated}}
        <Overlay @isOpen={{true}} @onClose={{this.closeAnimated}}>
          <div class='bg-surface-modal p-6 rounded-lg shadow-lg'>
            <h3 class='font-semibold mb-2'>Animated</h3>
            <p class='mb-4'>Rendered with @isOpen already true.</p>
            <Button @onPress={{this.closeAnimated}}>Close</Button>
          </div>
        </Overlay>
      {{/if}}

      {{#if this.showImmediate}}
        <Overlay
          @isOpen={{true}}
          @onClose={{this.closeImmediate}}
          @animateOnMount={{false}}
        >
          <div class='bg-surface-modal p-6 rounded-lg shadow-lg'>
            <h3 class='font-semibold mb-2'>No mount animation</h3>
            <p class='mb-4'>It is simply there. Closing still animates.</p>
            <Button @onPress={{this.closeImmediate}}>Close</Button>
          </div>
        </Overlay>
      {{/if}}
    </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='demo-stack demo-stack--wide items-center'>
      <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>
}

Note: Content wrapped in an inner element — a card, a dialog — is not the overlay element itself, so clicking your content never closes the overlay. What @closeOnOverlayElementClick covers is the area around it.

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 without trap Still auto-focuses the overlay when @disableFocusTrap={{true}}, unless @preventAutoFocus={{true}}
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}}
Nested scroll Reference counted, so closing an inner overlay keeps the outer lock

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.

@focusTrapOptions is merged over the defaults (clickOutsideDeactivates: true, allowOutsideClick: true), so naming one option does not cost you the rest. Setting either of those to false explicitly still holds outside clicks inside the trap. setReturnFocus is the supported way to send focus somewhere other than the trigger when the overlay closes — it runs when the trap tears down, which is after the close transition, so focusing an element yourself beforehand is undone.

Overlays that block scroll are reference counted, so a Modal that opens a Drawer stays locked until the last of them closes. Whatever inline overflow the page had before the first lock is restored, rather than blanked. Overlays rendered with @renderInPlace={{true}} or @blockScroll={{false}} never take part in that count.

Under prefers-reduced-motion: reduce, the built-in transitions drop their movement and keep only the fade: fade is unchanged, zoom and scale stop scaling, and the slideFrom* transitions fade in place instead of travelling. A custom @transition is yours to adapt.

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

API

Overlay

Element: HTMLDivElement

Arguments

Name Type Default Description
isOpen * boolean - Whether it is open or not
animateOnMount boolean true

Whether an overlay that is already open the first time it renders -- deep-linked open, or restored by a page refresh -- animates in.

When true (the default) the overlay waits for the browser's first paint before mounting, so the animation plays against a page the user has already seen. Set it to false for an already-open overlay that should simply be there, with no reveal. An overlay opened later by interaction animates either way, and so does closing.

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