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 { Modal } from 'frontile';
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='flex flex-col gap-4'>
<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 @intent='primary'>
Confirm
</Button>
</m.Footer>
</Modal>
</div>
</template>
}
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='flex flex-col gap-4'>
<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>
}
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='flex flex-col gap-4'>
<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>
}
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='flex flex-col gap-4'>
<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>
}
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='flex flex-col gap-4'>
<Button @intent='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' @intent='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
@intent='danger'
@onPress={{this.confirm}}
disabled={{this.isDeleting}}
>
{{#if this.isDeleting}}
Deleting...
{{else}}
Delete
{{/if}}
</Button>
</m.Footer>
</Modal>
</div>
</template>
}
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 = [];
@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(keys) {
this.category = keys;
}
@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[0],
message: this.message
});
this.isSubmitting = false;
this.resetForm();
this.toggle();
}
@action resetForm() {
this.name = '';
this.email = '';
this.category = [];
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='flex flex-col gap-4'>
<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}}
@selectedKeys={{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
@intent='primary'
@onPress={{this.handleSubmit}}
disabled={{this.isSubmitDisabled}}
>
{{#if this.isSubmitting}}
Sending...
{{else}}
Send Message
{{/if}}
</Button>
</m.Footer>
</Modal>
</div>
</template>
}
Control the visibility and behavior of the close button.
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='flex flex-col gap-4'>
<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 />
</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>
}
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='flex flex-col gap-4'>
<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 @intent='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 @intent='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 @intent='primary' @onPress={{this.toggleSecond}}>
Open Second Modal
</Button>
</m.Footer>
</Modal>
</div>
</template>
}
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 |
The dialog element renders as role="dialog" with tabindex="0", labelled by
aria-labelledby pointing at the id yielded as headerId — which is applied by
<m.Header>. A modal with no Header therefore has a dangling label reference. Either
render a Header, or put headerId on your own heading element, so assistive technology
has something to announce.
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 |
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.
Frontile does not set aria-modal or aria-describedby. Add them yourself if your dialog
needs them.
Element: <span class="hljs-title class_">HTMLDivElement</span>
| 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. |
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 |
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) For element id, string must be prefixed with |
transition
|
Object
|
{name: 'overlay-transition--zoom'}
|
The transition to be used in the Modal. |
transitionDuration
|
number
|
200
|
Duration of the animation |
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- |