Frontile

Modal

The Modal component is a centered dialog that appears over the main content. It's built on top of the Overlay component and includes all its accessibility features, plus modal-specific functionality like centered positioning and size variants.

Import

import { Modal } from 'frontile';

Usage

Basic Modal

A simple modal with header, body, and footer sections.

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

export default class BasicModal 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 Modal
      </Button>

      <Modal @isOpen={{this.isOpen}} @onClose={{this.toggle}} as |m|>
        <m.Header>
          Basic Modal
        </m.Header>
        <m.Body>
          <p class='mb-4'>This is the main content of the modal. Modals are
            great for displaying important information, forms, or confirmation
            dialogs.</p>
          <p>The modal appears centered on the screen with a backdrop that can
            be clicked to close it.</p>
        </m.Body>
        <m.Footer @class='flex gap-2'>
          <Button @onPress={{this.toggle}}>
            Cancel
          </Button>
          <Button @color='primary'>
            Confirm
          </Button>
        </m.Footer>
      </Modal>
    </div>
  </template>
}

Different Sizes

Control the modal size with the @size argument.

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

export default class ModalSizes extends Component {
  @tracked isOpen = false;
  @tracked selectedSize = 'lg';

  sizeOptions = [
    {
      key: 'xs',
      label: 'XS Size',
      title: 'Extra Small Modal',
      description:
        'This is an extra small modal (xs). Perfect for simple confirmations.'
    },
    {
      key: 'sm',
      label: 'SM Size',
      title: 'Small Modal',
      description:
        'This is a small modal (sm). Good for short forms or notifications.'
    },
    {
      key: 'md',
      label: 'MD Size',
      title: 'Medium Modal',
      description: 'This is a medium modal (md). Suitable for moderate content.'
    },
    {
      key: 'lg',
      label: 'LG Size (Default)',
      title: 'Large Modal',
      description:
        'This is a large modal (lg). This is the default size. Great for detailed forms or content.'
    },
    {
      key: 'xl',
      label: 'XL Size',
      title: 'Extra Large Modal',
      description:
        'This is an extra large modal (xl). Perfect for complex forms or detailed content.'
    },
    {
      key: 'full',
      label: 'Full Size',
      title: 'Full Size Modal',
      description:
        'This modal takes up the full screen. Use for complex interfaces or when you need maximum space.'
    }
  ];

  @action openModal(size) {
    this.selectedSize = size;
    this.isOpen = true;
  }

  @action closeModal() {
    this.isOpen = false;
  }

  get currentSizeOption() {
    return this.sizeOptions.find((option) => option.key === this.selectedSize);
  }

  <template>
    <div class='demo-stack demo-stack--wide items-center'>
      <div class='grid grid-cols-3 gap-2'>
        {{#each this.sizeOptions as |option|}}
          <Button @onPress={{fn this.openModal option.key}}>
            {{option.label}}
          </Button>
        {{/each}}
      </div>

      <Modal
        @isOpen={{this.isOpen}}
        @onClose={{this.closeModal}}
        @size={{this.selectedSize}}
        as |m|
      >
        <m.Header>{{this.currentSizeOption.title}}</m.Header>
        <m.Body>
          <p>{{this.currentSizeOption.description}}</p>
        </m.Body>
        <m.Footer>
          <Button @onPress={{this.closeModal}}>Close</Button>
        </m.Footer>
      </Modal>
    </div>
  </template>
}

Centered vs Standard Positioning

By default the modal sits toward the top of the screen. @isCentered={{true}} centers it vertically.

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

export default class ModalPositioning extends Component {
  @tracked standardOpen = false;
  @tracked centeredOpen = false;

  @action toggleStandard() {
    this.standardOpen = !this.standardOpen;
  }

  @action toggleCentered() {
    this.centeredOpen = !this.centeredOpen;
  }

  <template>
    <div class='demo-stack demo-stack--wide items-center'>
      <div class='flex gap-2'>
        <Button @onPress={{this.toggleStandard}}>
          Standard Position
        </Button>
        <Button @onPress={{this.toggleCentered}}>
          Centered Position
        </Button>
      </div>

      <Modal
        @isOpen={{this.standardOpen}}
        @onClose={{this.toggleStandard}}
        as |m|
      >
        <m.Header>Standard Positioning</m.Header>
        <m.Body>
          <p>This modal uses standard positioning (not vertically centered). It
            appears towards the top of the screen.</p>
        </m.Body>
        <m.Footer>
          <Button @onPress={{this.toggleStandard}}>Close</Button>
        </m.Footer>
      </Modal>

      <Modal
        @isOpen={{this.centeredOpen}}
        @onClose={{this.toggleCentered}}
        @isCentered={{true}}
        as |m|
      >
        <m.Header>Centered Positioning</m.Header>
        <m.Body>
          <p>This modal is vertically centered on the screen using @isCentered={{true}}.</p>
        </m.Body>
        <m.Footer>
          <Button @onPress={{this.toggleCentered}}>Close</Button>
        </m.Footer>
      </Modal>
    </div>
  </template>
}

Different Backdrop Types

Control the appearance of the backdrop behind the modal.

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

export default class ModalBackdrops extends Component {
  @tracked isOpen = false;
  @tracked selectedBackdrop = 'faded';

  backdropOptions = [
    {
      key: 'faded',
      label: 'Faded Backdrop',
      title: 'Faded Backdrop',
      description: 'Standard semi-transparent backdrop (default).'
    },
    {
      key: 'blur',
      label: 'Blurred Backdrop',
      title: 'Blurred Backdrop',
      description: 'Backdrop with blur effect behind the modal.'
    },
    {
      key: 'none',
      label: 'No Backdrop',
      title: 'No Backdrop',
      description: 'Modal without any backdrop overlay.'
    }
  ];

  @action openModal(backdrop) {
    this.selectedBackdrop = backdrop;
    this.isOpen = true;
  }

  @action closeModal() {
    this.isOpen = false;
  }

  get currentBackdropOption() {
    return this.backdropOptions.find(
      (option) => option.key === this.selectedBackdrop
    );
  }

  <template>
    <div class='demo-stack demo-stack--wide items-center'>
      <div class='grid grid-cols-2 gap-2'>
        {{#each this.backdropOptions as |option|}}
          <Button @onPress={{fn this.openModal option.key}}>
            {{option.label}}
          </Button>
        {{/each}}
      </div>

      <Modal
        @isOpen={{this.isOpen}}
        @onClose={{this.closeModal}}
        @backdrop={{this.selectedBackdrop}}
        @isCentered={{true}}
        as |m|
      >
        <m.Header>{{this.currentBackdropOption.title}}</m.Header>
        <m.Body>
          <p>{{this.currentBackdropOption.description}}</p>
          <p class='mt-2 text-sm text-neutral-soft'>Notice how the backdrop
            behind this modal changes based on the selected type.</p>
        </m.Body>
        <m.Footer>
          <Button @onPress={{this.closeModal}}>Close</Button>
        </m.Footer>
      </Modal>
    </div>
  </template>
}

Confirmation Dialog

A practical example showing a confirmation dialog pattern.

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

export default class ConfirmationDialog extends Component {
  @tracked isOpen = false;
  @tracked isDeleting = false;
  @tracked result = '';

  @action openDialog() {
    this.isOpen = true;
    this.result = '';
  }

  @action cancel() {
    this.isOpen = false;
    this.result = 'Action cancelled';
  }

  @action async confirm() {
    this.isDeleting = true;

    // Simulate async operation
    await new Promise((resolve) => setTimeout(resolve, 2000));

    this.isDeleting = false;
    this.isOpen = false;
    this.result = 'Item deleted successfully';
  }

  get allowClosing() {
    return !this.isDeleting;
  }

  <template>
    <div class='demo-stack demo-stack--wide items-center'>
      <Button @color='danger' @onPress={{this.openDialog}}>
        Delete Item
      </Button>

      {{#if this.result}}
        <div class='p-3 rounded border bg-neutral-subtle'>
          {{this.result}}
        </div>
      {{/if}}

      <Modal
        @isOpen={{this.isOpen}}
        @onClose={{this.cancel}}
        @size='sm'
        @isCentered={{true}}
        @allowClosing={{this.allowClosing}}
        as |m|
      >
        <m.Header>
          Confirm Deletion
        </m.Header>
        <m.Body>
          <div class='space-y-3'>
            <p>Are you sure you want to delete this item?</p>
            <p class='text-sm text-neutral'>This action cannot be undone.</p>

            {{#if this.isDeleting}}
              <div class='flex items-center space-x-2'>
                <Spinner @size='xs' @color='danger' />
                <span class='text-sm'>Deleting...</span>
              </div>
            {{/if}}
          </div>
        </m.Body>
        <m.Footer @class='flex gap-4 [&>*]:flex-1'>
          <Button @onPress={{this.cancel}} disabled={{this.isDeleting}}>
            Cancel
          </Button>
          <Button
            @color='danger'
            @onPress={{this.confirm}}
            disabled={{this.isDeleting}}
          >
            {{#if this.isDeleting}}
              Deleting...
            {{else}}
              Delete
            {{/if}}
          </Button>
        </m.Footer>
      </Modal>
    </div>
  </template>
}

Form Modal

A modal containing a complete form with validation.

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Modal } from 'frontile';
import { Button } from 'frontile';
import { Input, Textarea, Select } from 'frontile';
import { on } from '@ember/modifier';

export default class FormModal extends Component {
  @tracked isOpen = false;
  @tracked name = '';
  @tracked email = '';
  @tracked category = null;
  @tracked message = '';
  @tracked isSubmitting = false;

  categories = [
    { key: 'support', label: 'Support' },
    { key: 'sales', label: 'Sales' },
    { key: 'feedback', label: 'Feedback' },
    { key: 'other', label: 'Other' }
  ];

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

  @action updateName(value) {
    this.name = value;
  }

  @action updateEmail(value) {
    this.email = value;
  }

  @action updateCategory(key) {
    this.category = key;
  }

  @action updateMessage(value) {
    this.message = value;
  }

  @action async handleSubmit(event) {
    event.preventDefault();

    if (!this.name || !this.email || !this.message) {
      return;
    }

    this.isSubmitting = true;

    // Simulate API call
    await new Promise((resolve) => setTimeout(resolve, 2000));

    console.log('Form submitted:', {
      name: this.name,
      email: this.email,
      category: this.category,
      message: this.message
    });

    this.isSubmitting = false;
    this.resetForm();
    this.toggle();
  }

  @action resetForm() {
    this.name = '';
    this.email = '';
    this.category = null;
    this.message = '';
  }

  get isValid() {
    return this.name && this.email && this.message;
  }

  get isSubmitDisabled() {
    return !this.isValid || this.isSubmitting;
  }

  get allowClosing() {
    return !this.isSubmitting;
  }

  <template>
    <div class='demo-stack demo-stack--wide items-center'>
      <Button @onPress={{this.toggle}}>
        Open Contact Form
      </Button>

      <Modal
        @isOpen={{this.isOpen}}
        @onClose={{this.toggle}}
        @size='lg'
        @allowClosing={{this.allowClosing}}
        as |m|
      >
        <m.Header>
          Contact Us
        </m.Header>
        <m.Body>
          <form {{on 'submit' this.handleSubmit}} class='space-y-4'>
            <Input
              @label='Name'
              @value={{this.name}}
              @onInput={{this.updateName}}
              required
              disabled={{this.isSubmitting}}
            />

            <Input
              @label='Email'
              @type='email'
              @value={{this.email}}
              @onInput={{this.updateEmail}}
              required
              disabled={{this.isSubmitting}}
            />

            <Select
              @label='Category'
              @items={{this.categories}}
              @selectedKey={{this.category}}
              @onSelectionChange={{this.updateCategory}}
              @placeholder='Select a category'
              disabled={{this.isSubmitting}}
            />

            <Textarea
              @label='Message'
              @value={{this.message}}
              @onInput={{this.updateMessage}}
              @rows={{4}}
              required
              disabled={{this.isSubmitting}}
            />
          </form>
        </m.Body>
        <m.Footer @class='flex gap-2'>
          <Button @onPress={{this.toggle}} disabled={{this.isSubmitting}}>
            Cancel
          </Button>
          <Button
            @color='primary'
            @onPress={{this.handleSubmit}}
            disabled={{this.isSubmitDisabled}}
          >
            {{#if this.isSubmitting}}
              Sending...
            {{else}}
              Send Message
            {{/if}}
          </Button>
        </m.Footer>
      </Modal>
    </div>
  </template>
}

Close Button Control

Control the visibility and behavior of the close button. When you render the yielded m.CloseButton yourself (the "Custom Close Button" example below), add data-part="close-button" to it explicitly — Modal cannot inject that attribute into markup you write in your own template, so it is on you to carry it forward for anatomy consumers styling or querying by data-part.

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

export default class ModalCloseButton extends Component {
  @tracked normalOpen = false;
  @tracked noCloseButtonOpen = false;
  @tracked customCloseOpen = false;

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

  @action toggleNoCloseButton() {
    this.noCloseButtonOpen = !this.noCloseButtonOpen;
  }

  @action toggleCustomClose() {
    this.customCloseOpen = !this.customCloseOpen;
  }

  <template>
    <div class='demo-stack demo-stack--wide items-center'>
      <div class='flex gap-2'>
        <Button @onPress={{this.toggleNormal}}>
          Normal Close Button
        </Button>
        <Button @onPress={{this.toggleNoCloseButton}}>
          No Close Button
        </Button>
        <Button @onPress={{this.toggleCustomClose}}>
          Custom Close Button
        </Button>
      </div>

      <Modal @isOpen={{this.normalOpen}} @onClose={{this.toggleNormal}} as |m|>
        <m.Header>Normal Close Button</m.Header>
        <m.Body>
          <p>This modal has the default close button in the top right corner.</p>
        </m.Body>
        <m.Footer>
          <Button @onPress={{this.toggleNormal}}>Done</Button>
        </m.Footer>
      </Modal>

      <Modal
        @isOpen={{this.noCloseButtonOpen}}
        @onClose={{this.toggleNoCloseButton}}
        @allowCloseButton={{false}}
        as |m|
      >
        <m.Header>No Close Button</m.Header>
        <m.Body>
          <p>This modal has no close button. You can still close it by clicking
            the backdrop or pressing Escape.</p>
        </m.Body>
        <m.Footer>
          <Button @onPress={{this.toggleNoCloseButton}}>
            Close from Footer
          </Button>
        </m.Footer>
      </Modal>

      <Modal
        @isOpen={{this.customCloseOpen}}
        @onClose={{this.toggleCustomClose}}
        @allowCloseButton={{false}}
        as |m|
      >
        <m.Header>
          <div class='flex justify-between items-center'>
            <span>Custom Close Button</span>
            <m.CloseButton data-part="close-button" />
          </div>
        </m.Header>
        <m.Body>
          <p>This modal uses a custom close button placed in the header using
            the yielded CloseButton component.</p>
        </m.Body>
        <m.Footer>
          <Button @onPress={{this.toggleCustomClose}}>Done</Button>
        </m.Footer>
      </Modal>
    </div>
  </template>
}

Nested Modals

Example showing modals that can open other modals.

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

export default class NestedModals extends Component {
  @tracked firstModalOpen = false;
  @tracked secondModalOpen = false;
  @tracked thirdModalOpen = false;

  @action toggleFirst() {
    this.firstModalOpen = !this.firstModalOpen;
  }

  @action toggleSecond() {
    this.secondModalOpen = !this.secondModalOpen;
  }

  @action toggleThird() {
    this.thirdModalOpen = !this.thirdModalOpen;
  }

  @action closeAll() {
    this.thirdModalOpen = false;
    this.secondModalOpen = false;
    this.firstModalOpen = false;
  }

  <template>
    <div class='demo-stack demo-stack--wide items-center'>
      <Button @onPress={{this.toggleFirst}}>
        Open First Modal
      </Button>

      <!-- First Modal -->
      <Modal
        @isOpen={{this.firstModalOpen}}
        @onClose={{this.toggleFirst}}
        as |m|
      >
        <m.Header>First Modal</m.Header>
        <m.Body>
          <p class='mb-4'>This is the first modal. You can open another modal
            from here.</p>
          <p class='text-sm text-neutral'>Notice how the backdrop becomes darker
            with each modal layer.</p>

          <!-- Second Modal -->
          <Modal
            @isOpen={{this.secondModalOpen}}
            @onClose={{this.toggleSecond}}
            @size='md'
            as |m|
          >
            <m.Header>Second Modal</m.Header>
            <m.Body>
              <p class='mb-4'>This is the second modal, opened from the first
                one.</p>
              <p class='text-sm text-neutral'>You can continue nesting modals as
                needed.</p>

              <!-- Third Modal -->
              <Modal
                @isOpen={{this.thirdModalOpen}}
                @onClose={{this.toggleThird}}
                @size='sm'
                as |m|
              >
                <m.Header>Third Modal</m.Header>
                <m.Body>
                  <p class='mb-4'>This is the third and final modal in this
                    example.</p>
                  <p class='text-sm text-neutral'>Each modal maintains its own
                    focus trap and can be closed independently.</p>
                </m.Body>
                <m.Footer @class='flex gap-2'>
                  <Button @onPress={{this.toggleThird}}>
                    Close This
                  </Button>
                  <Button @color='danger' @onPress={{this.closeAll}}>
                    Close All
                  </Button>
                </m.Footer>
              </Modal>
            </m.Body>
            <m.Footer @class='flex gap-2'>
              <Button @onPress={{this.toggleSecond}}>
                Close
              </Button>
              <Button @color='primary' @onPress={{this.toggleThird}}>
                Open Third Modal
              </Button>
            </m.Footer>
          </Modal>

        </m.Body>
        <m.Footer @class='flex gap-2'>
          <Button @onPress={{this.toggleFirst}}>
            Close
          </Button>
          <Button @color='primary' @onPress={{this.toggleSecond}}>
            Open Second Modal
          </Button>
        </m.Footer>
      </Modal>

    </div>
  </template>
}

Modals that start open

A modal whose @isOpen is already true the first time it renders — deep-linked open, or restored by a page refresh — 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 modal should simply be there, with no reveal.

Anatomy

Modal yields the pieces you assemble the dialog from:

Yielded Purpose
Header Heading region; applies the id that aria-labelledby points at
Body Main content area
Footer Action row
CloseButton Styled close button wired to @onClose
headerId The id Header uses, for labelling your own heading instead

Accessibility

The dialog element renders as role="dialog" with tabindex="0" and aria-modal="true", labelled by aria-labelledby pointing at the id yielded as headerId — which is applied by <m.Header>. aria-labelledby is only rendered while a Header is actually on the page, so a modal without one has no dangling reference — but it also has no accessible name. Give it one: render a Header, or pass your own label through attributes.

<Modal @isOpen={{this.isOpen}} @onClose={{this.close}} aria-label="Delete account" as |m|>
  <m.Body>This cannot be undone.</m.Body>
</Modal>

If you label the dialog with a heading of your own rather than <m.Header>, pass aria-labelledby yourself — putting the yielded headerId on a heading does not label the dialog by itself, because nothing points at it:

<Modal @isOpen={{this.isOpen}} @onClose={{this.close}} aria-labelledby={{this.titleId}} as |m|>
  <h2 id={{this.titleId}}>My Title</h2>
  <m.Body>My Content</m.Body>
</Modal>

A modal with no accessible name — no Header, aria-label, or aria-labelledby — is invalid. Frontile reports this during development with the warning id frontile.modal.missing-accessible-name so it can be corrected before release.

aria-modal="true" is dropped when @disableFocusTrap={{true}}: with the trap off the page behind really is reachable, and claiming otherwise would mislead screen reader users. Note that the modal still auto-focuses itself in this case, unless @preventAutoFocus={{true}} is also passed — see Overlay .

Behavior inherited from Overlay :

Behavior Detail
Focus on open Moves into the modal, and a focus trap keeps it there
Focus on close Returns to whatever was focused before opening
Escape Closes, unless @closeOnEscapeKey={{false}}
Backdrop click Closes, unless @closeOnOutsideClick={{false}}
Body scroll Blocked while open (reference counted for nesting)

The modal needs at least one focusable element inside it, or the focus trap has nowhere to put focus. Note that @allowClosing={{false}} disables Escape, backdrop click and the close button together, which leaves a keyboard user no way out — reserve it for flows that provide their own explicit resolution.

Under prefers-reduced-motion: reduce, the modal fades in without the zoom.

Frontile does not set aria-describedby. Add it yourself if your dialog needs it.

API

Modal

Element: HTMLDivElement

Arguments

Name Type Default Description
isOpen * boolean - Whether it is open or not
allowCloseButton boolean true If set to false, the close button will not be displayed.
allowClosing boolean true If set to false, the close button will not be displayed, closeOnOutsideClick will be set to false, and closeOnEscapeKey will also be set to false.
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.
classes SlotsToClasses<'base' | 'body' | 'footer' | 'header' | 'closeButton'> - Class names for each slot of the component, merged with the theme's.
closeButtonSize enum - The Close Button size.
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
didClose function - A function that will be called when closing is finished executing, this includes waiting for animations/transitions to finish.
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
isCentered boolean false If set to true, the modal will be vertically centered
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.
renderInPlace boolean false Whether to render in place or in the specified/default destination
size enum 'lg' The Modal size.
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--zoom'} The transition to be used in the Modal.
transitionDuration number 200 Duration of the animation

Blocks

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