The Drawer component is a slide-out panel that appears from any edge of the screen. It's built on top of the Overlay component and includes all its accessibility features, plus drawer-specific functionality like multiple placement options and sizes.
import { Drawer } from 'frontile';
A simple drawer that slides in from the right side.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Drawer } from 'frontile';
import { Button } from 'frontile';
export default class BasicDrawer extends Component {
@tracked isOpen = false;
@action toggle() {
this.isOpen = !this.isOpen;
}
<template>
<div class='demo-stack demo-stack--wide items-center'>
<Button @size='sm' @onPress={{this.toggle}}>
Open Drawer
</Button>
<Drawer @isOpen={{this.isOpen}} @onClose={{this.toggle}} as |d|>
<d.Header>
Basic Drawer
</d.Header>
<d.Body>
<p class='mb-4'>This is the main content of the drawer. You can put
any content here including forms, lists, or other components.</p>
<p>The drawer slides in from the right side by default and includes a
close button in the top right corner.</p>
</d.Body>
<d.Footer @class='flex gap-2'>
<Button @size='sm' @onPress={{this.toggle}}>
Cancel
</Button>
<Button @size='sm' @color='primary'>
Save
</Button>
</d.Footer>
</Drawer>
</div>
</template>
}
@variant controls how the header, body and footer relate to each other. sectioned gives
the drawer a black header band, a body on its own surface and a solid footer — this is the
banded treatment. flat keeps every region on the same surface as the modal — the flat look
Drawer used before v0.18, kept for consumers who don't want the restyle.
The header API is identical in both variants: @title/@description and a block yielding
h.Icon, h.Title and h.Description work the same way regardless of which variant is
selected.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { fn } from '@ember/helper';
import { Drawer } from 'frontile';
import { Button } from 'frontile';
export default class DrawerVariants extends Component {
@tracked isOpen = false;
@tracked selectedVariant = 'sectioned';
variants = ['sectioned', 'flat'];
@action openDrawer(variant) {
this.selectedVariant = variant;
this.isOpen = true;
}
@action closeDrawer() {
this.isOpen = false;
}
<template>
<div class='flex gap-2'>
{{#each this.variants as |variant|}}
<Button @size='sm' @onPress={{fn this.openDrawer variant}}>
{{variant}}
</Button>
{{/each}}
</div>
<Drawer
@isOpen={{this.isOpen}}
@onClose={{this.closeDrawer}}
@variant={{this.selectedVariant}}
as |d|
>
<d.Header
@title='{{this.selectedVariant}} variant'
@description='Switch variants with the buttons above.'
/>
<d.Body>
<p>This is the body content, on its own surface in `sectioned` and flat
in `flat`.</p>
</d.Body>
<d.Footer @class='flex gap-2'>
<Button @size='sm' @onPress={{this.closeDrawer}}>Close</Button>
</d.Footer>
</Drawer>
</template>
}
The same switch works with the block form of <d.Header>, including an icon:
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { fn } from '@ember/helper';
import { Drawer } from 'frontile';
import { Button } from 'frontile';
import { SettingsIcon } from 'site/components/icons';
export default class DrawerVariantsIcon extends Component {
@tracked isOpen = false;
@tracked selectedVariant = 'sectioned';
variants = ['sectioned', 'flat'];
@action openDrawer(variant) {
this.selectedVariant = variant;
this.isOpen = true;
}
@action closeDrawer() {
this.isOpen = false;
}
<template>
<div class='flex gap-2'>
{{#each this.variants as |variant|}}
<Button @size='sm' @onPress={{fn this.openDrawer variant}}>
{{variant}}
with icon
</Button>
{{/each}}
</div>
<Drawer
@isOpen={{this.isOpen}}
@onClose={{this.closeDrawer}}
@variant={{this.selectedVariant}}
as |d|
>
<d.Header
@title='{{this.selectedVariant}} with an icon'
@description='The icon and text placement come from the block form.'
as |h|
>
<h.Icon><SettingsIcon /></h.Icon>
<h.Title />
<h.Description />
</d.Header>
<d.Body>
<p>Icon, title and description are placed by the block, unaffected by
which variant is active.</p>
</d.Body>
</Drawer>
</template>
}
<d.Header> accepts @title and @description directly, or a block yielding h.Icon,
h.Title and h.Description for when you need to place them yourself — a blockless
<h.Title /> or <h.Description /> falls back to @title / @description.
An icon tops out with the title when there is a description under it, and centers against the title when there isn't.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Drawer } from 'frontile';
import { Button } from 'frontile';
export default class DrawerHeaderArgs extends Component {
@tracked isOpen = false;
@action toggle() {
this.isOpen = !this.isOpen;
}
<template>
<Button @size='sm' @onPress={{this.toggle}}>
Open Drawer
</Button>
<Drawer @isOpen={{this.isOpen}} @onClose={{this.toggle}} as |d|>
<d.Header
@title='Account settings'
@description='Update your name, email and password.'
/>
<d.Body>
<p>The title and description above came from `@title` and
`@description`, with no block needed.</p>
</d.Body>
</Drawer>
</template>
}
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Drawer } from 'frontile';
import { Button } from 'frontile';
import { SettingsIcon } from 'site/components/icons';
export default class DrawerHeaderBlock extends Component {
@tracked isOpen = false;
@action toggle() {
this.isOpen = !this.isOpen;
}
<template>
<Button @size='sm' @onPress={{this.toggle}}>
Open Drawer
</Button>
<Drawer @isOpen={{this.isOpen}} @onClose={{this.toggle}} as |d|>
<d.Header
@title='Account settings'
@description='Update your name, email and password.'
as |h|
>
<h.Icon><SettingsIcon /></h.Icon>
<h.Title />
<h.Description />
</d.Header>
<d.Body>
<p>The title and description here are still the same `@title` and
`@description`, only placed alongside an icon by the block.</p>
</d.Body>
</Drawer>
</template>
}
Use the :actions named block to put controls beside the close button.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Drawer, Button } from 'frontile';
export default class DrawerHeaderActions extends Component {
@tracked isOpen = false;
@tracked onlyStarred = false;
@action toggle() {
this.isOpen = !this.isOpen;
}
@action toggleStarred() {
this.onlyStarred = !this.onlyStarred;
}
<template>
<Button @size='sm' @onPress={{this.toggle}}>Open Drawer</Button>
<Drawer @isOpen={{this.isOpen}} @onClose={{this.toggle}} as |d|>
<d.Header @title='Filters' @description='Narrow the results below.'>
<:actions>
<Button @size='sm' @color='primary' @onPress={{this.toggleStarred}}>
{{if this.onlyStarred 'Show all' 'Only starred'}}
</Button>
</:actions>
</d.Header>
<d.Body>
<p>{{if
this.onlyStarred
'Showing starred results.'
'Showing all results.'
}}</p>
</d.Body>
</Drawer>
</template>
}
Actions sit in flow, so a taller control makes the header band taller: a title-only header is 72px on its own and grows to fit whatever you add. The close button does not affect the band's height.
The header keeps a lane clear on its right for the close button, and drops it when
@allowCloseButton={{false}}.
Using :actions means your main header content moves into an explicit :default block:
<d.Header as |h|>
<:default>
<h.Title>Filters</h.Title>
</:default>
<:actions>
<Button @size="sm">Reset</Button>
</:actions>
</d.Header>
Header, Body and Footer are yielded components, not fixed slots — anything you
render between them becomes a sibling in the drawer's column. The drawer applies no
padding of its own, so a full-bleed element placed there spans the panel edge to edge.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Drawer, Button, Alert } from 'frontile';
import { SettingsIcon } from 'site/components/icons';
export default class DrawerWithBanner extends Component {
@tracked isOpen = false;
@action toggle() {
this.isOpen = !this.isOpen;
}
<template>
<Button @size='sm' @onPress={{this.toggle}}>Open Drawer</Button>
<Drawer @isOpen={{this.isOpen}} @onClose={{this.toggle}} as |d|>
<d.Header as |h|>
<h.Icon><SettingsIcon /></h.Icon>
<h.Title>Drawer title</h.Title>
<h.Description>Supporting text</h.Description>
</d.Header>
<Alert
@layout='banner'
@variant='solid'
@status='warning'
@title='This is the banner text'
/>
<d.Body>
<p>The banner sits between the header and this content, spanning the
full width of the drawer.</p>
</d.Body>
<d.Footer>
<Button
@size='sm'
@color='primary'
@variant='outline'
@onPress={{this.toggle}}
>
Secondary
</Button>
<Button @size='sm' @color='primary'>Primary</Button>
</d.Footer>
</Drawer>
</template>
}
Drawers can slide in from any edge of the screen.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { fn } from '@ember/helper';
import { Drawer } from 'frontile';
import { Button } from 'frontile';
export default class DrawerPlacements extends Component {
@tracked isOpen = false;
@tracked selectedPlacement = 'right';
placements = [
{
key: 'top',
label: 'Top Drawer',
title: 'Top Drawer',
description: 'This drawer slides down from the top of the screen.'
},
{
key: 'bottom',
label: 'Bottom Drawer',
title: 'Bottom Drawer',
description: 'This drawer slides up from the bottom of the screen.'
},
{
key: 'left',
label: 'Left Drawer',
title: 'Left Drawer',
description: 'This drawer slides in from the left side of the screen.'
},
{
key: 'right',
label: 'Right Drawer',
title: 'Right Drawer',
description: 'This drawer slides in from the right side of the screen.'
}
];
@action openDrawer(placement) {
this.selectedPlacement = placement;
this.isOpen = true;
}
@action closeDrawer() {
this.isOpen = false;
}
get currentPlacement() {
return this.placements.find(
(placement) => placement.key === this.selectedPlacement
);
}
<template>
<div class='demo-stack demo-stack--wide items-center'>
<div class='grid grid-cols-2 gap-2'>
{{#each this.placements as |placement|}}
<Button @size='sm' @onPress={{fn this.openDrawer placement.key}}>
{{placement.label}}
</Button>
{{/each}}
</div>
<Drawer
@isOpen={{this.isOpen}}
@onClose={{this.closeDrawer}}
@placement={{this.selectedPlacement}}
as |d|
>
<d.Header>{{this.currentPlacement.title}}</d.Header>
<d.Body>
<p>{{this.currentPlacement.description}}</p>
</d.Body>
</Drawer>
</div>
</template>
}
Control the drawer 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 { Drawer } from 'frontile';
import { Button } from 'frontile';
import { on } from '@ember/modifier';
export default class DrawerSizes extends Component {
@tracked isOpen = false;
@tracked selectedSize = 'md';
sizeOptions = [
{
key: 'xs',
label: 'XS Size',
title: 'Extra Small Drawer',
description: 'This is an extra small drawer (xs).'
},
{
key: 'sm',
label: 'SM Size',
title: 'Small Drawer',
description: 'This is a small drawer (sm).'
},
{
key: 'md',
label: 'MD Size (Default)',
title: 'Medium Drawer',
description: 'This is a medium drawer (md). This is the default size.'
},
{
key: 'lg',
label: 'LG Size',
title: 'Large Drawer',
description: 'This is a large drawer (lg).'
},
{
key: 'xl',
label: 'XL Size',
title: 'Extra Large Drawer',
description: 'This is an extra large drawer (xl).'
},
{
key: 'full',
label: 'Full Size',
title: 'Full Size Drawer',
description: 'This drawer takes up the full width/height of the screen.'
}
];
@action openDrawer(size) {
this.selectedSize = size;
this.isOpen = true;
}
@action closeDrawer() {
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 @size='sm' @onPress={{fn this.openDrawer option.key}}>
{{option.label}}
</Button>
{{/each}}
</div>
<Drawer
@isOpen={{this.isOpen}}
@onClose={{this.closeDrawer}}
@size={{this.selectedSize}}
as |d|
>
<d.Header>{{this.currentSizeOption.title}}</d.Header>
<d.Body>
<p>{{this.currentSizeOption.description}}</p>
</d.Body>
</Drawer>
</div>
</template>
}
@allowDragToClose is on by default for top/bottom and off by default for left/right.
Passing true opts any placement in; passing false turns it off for any placement. It has no
effect when @allowClosing={{false}} — a non-dismissible drawer stays non-dismissible either
way. Side placements default to off because a horizontal drag starting at a screen edge is
easy to miss; pass @allowDragToClose={{true}} to enable it there too.
The handle is a real button (labelled "Close drawer"), so keyboard and assistive-technology
users can close the drawer by activating it, without performing a gesture at all — and it
closes on click as well as on drag for everyone else.
A press on the body itself (not just the handle) can also dismiss the drawer, once the body's own scroll position is already at the edge the drag pulls away from — so a drag toward the handle's side dismisses, while scrolling through a longer body still scrolls normally instead of being hijacked. This distinction is decided a few pixels into the gesture, not at the very first touch, so tapping or starting to scroll never has a false start.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { fn } from '@ember/helper';
import { Drawer } from 'frontile';
import { Button } from 'frontile';
export default class DrawerDragBottom extends Component {
@tracked isOpen = false;
@tracked selectedVariant = 'sectioned';
variants = ['sectioned', 'flat'];
@action openDrawer(variant) {
this.selectedVariant = variant;
this.isOpen = true;
}
@action closeDrawer() {
this.isOpen = false;
}
<template>
<div class='flex gap-2'>
{{#each this.variants as |variant|}}
<Button @size='sm' @onPress={{fn this.openDrawer variant}}>
Open Bottom Drawer ({{variant}})
</Button>
{{/each}}
</div>
<Drawer
@isOpen={{this.isOpen}}
@onClose={{this.closeDrawer}}
@placement='bottom'
@variant={{this.selectedVariant}}
as |d|
>
<d.Header @title='Drag me down' @description='Or use the close button.' />
<d.Body>
<p>Drag the handle at the top of this drawer down to dismiss it, or
release early to have it spring back. The handle bar looks the same in
`default` and `ghost`.</p>
</d.Body>
</Drawer>
</template>
}
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Drawer } from 'frontile';
import { Button } from 'frontile';
export default class DrawerDragRight extends Component {
@tracked isOpen = false;
@action toggle() {
this.isOpen = !this.isOpen;
}
<template>
<Button @size='sm' @onPress={{this.toggle}}>
Open Right Drawer
</Button>
<Drawer
@isOpen={{this.isOpen}}
@onClose={{this.toggle}}
@placement='right'
@allowDragToClose={{true}}
as |d|
>
<d.Header
@title='Opted in'
@description='Right drawers need @allowDragToClose to get the handle.'
/>
<d.Body>
<p>Drag the handle on the left edge toward the left to dismiss.</p>
</d.Body>
</Drawer>
</template>
}
Control the appearance of the backdrop behind the drawer.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { fn } from '@ember/helper';
import { Drawer } from 'frontile';
import { Button } from 'frontile';
export default class DrawerBackdrops 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 drawer.'
},
{
key: 'none',
label: 'No Backdrop',
title: 'No Backdrop',
description: 'Drawer without any backdrop overlay.'
}
];
@action openDrawer(backdrop) {
this.selectedBackdrop = backdrop;
this.isOpen = true;
}
@action closeDrawer() {
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 @size='sm' @onPress={{fn this.openDrawer option.key}}>
{{option.label}}
</Button>
{{/each}}
</div>
<Drawer
@isOpen={{this.isOpen}}
@onClose={{this.closeDrawer}}
@backdrop={{this.selectedBackdrop}}
@placement='right'
@size='md'
as |d|
>
<d.Header>{{this.currentBackdropOption.title}}</d.Header>
<d.Body>
<p>{{this.currentBackdropOption.description}}</p>
<p class='mt-2 text-sm text-neutral-soft'>Notice how the backdrop
behind this drawer changes based on the selected type.</p>
</d.Body>
<d.Footer>
<Button @size='sm' @onPress={{this.closeDrawer}}>Close</Button>
</d.Footer>
</Drawer>
</div>
</template>
}
Control the visibility and behavior of the close button. When you render the
yielded d.CloseButton yourself (the "Custom Close Button" example below),
add data-part="close-button" to it explicitly — Drawer 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 { Drawer } from 'frontile';
import { Button } from 'frontile';
export default class DrawerCloseButton 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 @size='sm' @onPress={{this.toggleNormal}}>
Normal Close Button
</Button>
<Button @size='sm' @onPress={{this.toggleNoCloseButton}}>
No Close Button
</Button>
<Button @size='sm' @onPress={{this.toggleCustomClose}}>
Custom Close Button
</Button>
</div>
<Drawer @isOpen={{this.normalOpen}} @onClose={{this.toggleNormal}} as |d|>
<d.Header>Normal Close Button</d.Header>
<d.Body>
<p>This drawer has the default close button in the top right corner.</p>
</d.Body>
</Drawer>
<Drawer
@isOpen={{this.noCloseButtonOpen}}
@onClose={{this.toggleNoCloseButton}}
@allowCloseButton={{false}}
as |d|
>
<d.Header>No Close Button</d.Header>
<d.Body>
<p>This drawer has no close button. You can still close it by clicking
the backdrop or pressing Escape.</p>
</d.Body>
<d.Footer>
<Button @size='sm' @onPress={{this.toggleNoCloseButton}}>
Close from Footer
</Button>
</d.Footer>
</Drawer>
<Drawer
@isOpen={{this.customCloseOpen}}
@onClose={{this.toggleCustomClose}}
@allowCloseButton={{false}}
as |d|
>
<d.Header>
Custom Close Button
<d.CloseButton data-part='close-button' />
</d.Header>
<d.Body>
<p>This drawer uses a custom close button placed in the header using
the yielded CloseButton component.</p>
</d.Body>
</Drawer>
</div>
</template>
}
A drawer that cannot be closed by normal means.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Drawer } from 'frontile';
import { Button } from 'frontile';
import { ProgressBar } from 'frontile';
export default class NonDismissibleDrawer extends Component {
@tracked isOpen = false;
@tracked progress = 0;
@tracked isProcessing = false;
@action toggle() {
this.isOpen = !this.isOpen;
}
@action startProcess() {
this.isProcessing = true;
this.progress = 0;
const interval = setInterval(() => {
this.progress += 10;
if (this.progress >= 100) {
clearInterval(interval);
this.isProcessing = false;
}
}, 500);
}
@action forceClose() {
this.isProcessing = false;
this.progress = 0;
this.toggle();
}
get allowClosing() {
return !this.isProcessing;
}
<template>
<div class='demo-stack demo-stack--wide items-center'>
<Button @size='sm' @onPress={{this.toggle}}>
Open Processing Drawer
</Button>
<Drawer
@isOpen={{this.isOpen}}
@onClose={{this.toggle}}
@allowClosing={{this.allowClosing}}
as |d|
>
<d.Header>
Processing Data
</d.Header>
<d.Body>
<div class='space-y-4'>
<p>This drawer cannot be closed while processing is in progress.</p>
{{#if this.isProcessing}}
<ProgressBar
@progress={{this.progress}}
@label='Progress: {{this.progress}}%'
@color='success'
/>
{{else}}
<p class='text-success'>Ready to process data.</p>
{{/if}}
</div>
</d.Body>
<d.Footer @class='flex gap-2'>
{{#if this.isProcessing}}
<Button @size='sm' disabled={{true}}>
Processing...
</Button>
<Button @size='sm' @color='danger' @onPress={{this.forceClose}}>
Force Close
</Button>
{{else}}
<Button @size='sm' @onPress={{this.toggle}}>
Cancel
</Button>
<Button @size='sm' @color='primary' @onPress={{this.startProcess}}>
Start Processing
</Button>
{{/if}}
</d.Footer>
</Drawer>
</div>
</template>
}
A drawer 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 drawer should simply be there, with no
reveal.
Drawer yields the pieces you assemble it from:
| Yielded | Purpose |
|---|---|
d.Header |
Heading region; applies the id that aria-labelledby points at. |
d.Body |
Main content area. |
d.Footer |
Action row. |
d.CloseButton |
Styled close button wired to @onClose. |
d.headerId |
The id Header uses, for labelling your own heading instead. |
The default close button is rendered inside <d.Header> when one is present. A drawer with
no Header falls back to a standalone close button in its top-right corner.
A practical example showing a form inside a drawer.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Drawer } from 'frontile';
import { Button } from 'frontile';
import { Input, Textarea } from 'frontile';
import { on } from '@ember/modifier';
export default class DrawerForm extends Component {
@tracked isOpen = false;
@tracked name = '';
@tracked email = '';
@tracked message = '';
@action toggle() {
this.isOpen = !this.isOpen;
}
@action handleSubmit(event) {
event.preventDefault();
// Handle form submission
console.log('Form submitted:', {
name: this.name,
email: this.email,
message: this.message
});
this.toggle();
}
@action updateName(value) {
this.name = value;
}
@action updateEmail(value) {
this.email = value;
}
@action updateMessage(value) {
this.message = value;
}
<template>
<div class='demo-stack demo-stack--wide items-center'>
<Button @size='sm' @onPress={{this.toggle}}>
Open Contact Form
</Button>
<Drawer
@isOpen={{this.isOpen}}
@onClose={{this.toggle}}
@size='lg'
as |d|
>
<d.Header>
Contact Us
</d.Header>
<d.Body>
<form {{on 'submit' this.handleSubmit}} class='space-y-4'>
<Input
@label='Name'
@value={{this.name}}
@onInput={{this.updateName}}
required
/>
<Input
@label='Email'
@type='email'
@value={{this.email}}
@onInput={{this.updateEmail}}
required
/>
<Textarea
@label='Message'
@value={{this.message}}
@onInput={{this.updateMessage}}
@rows={{5}}
required
/>
</form>
</d.Body>
<d.Footer @class='flex gap-2'>
<Button @size='sm' @onPress={{this.toggle}}>
Cancel
</Button>
<Button @size='sm' @color='primary' @onPress={{this.handleSubmit}}>
Send Message
</Button>
</d.Footer>
</Drawer>
</div>
</template>
}
A drawer used for navigation with a list of links.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { fn } from '@ember/helper';
import { Drawer } from 'frontile';
import { Button } from 'frontile';
import { on } from '@ember/modifier';
import { Divider } from 'frontile';
export default class NavigationDrawer extends Component {
@tracked isOpen = false;
@action toggle() {
this.isOpen = !this.isOpen;
}
@action navigateTo(page) {
console.log('Navigate to:', page);
this.toggle();
}
<template>
<div class='demo-stack demo-stack--wide items-center'>
<Button @size='sm' @onPress={{this.toggle}}>
Open Navigation
</Button>
<Drawer
@isOpen={{this.isOpen}}
@onClose={{this.toggle}}
@placement='left'
@size='sm'
as |d|
>
<d.Header>
App Navigation
</d.Header>
<d.Body>
<nav class='space-y-2'>
<button
{{on 'click' (fn this.navigateTo 'dashboard')}}
class='w-full text-left px-3 py-2 rounded hover:bg-neutral-subtle transition-colors'
>
🏠 Dashboard
</button>
<button
{{on 'click' (fn this.navigateTo 'profile')}}
class='w-full text-left px-3 py-2 rounded hover:bg-neutral-subtle transition-colors'
>
👤 Profile
</button>
<button
{{on 'click' (fn this.navigateTo 'settings')}}
class='w-full text-left px-3 py-2 rounded hover:bg-neutral-subtle transition-colors'
>
⚙️ Settings
</button>
<button
{{on 'click' (fn this.navigateTo 'help')}}
class='w-full text-left px-3 py-2 rounded hover:bg-neutral-subtle transition-colors'
>
❓ Help
</button>
<Divider />
<button
{{on 'click' (fn this.navigateTo 'logout')}}
class='w-full text-left px-3 py-2 rounded hover:bg-danger-subtle text-danger transition-colors'
>
🚪 Logout
</button>
</nav>
</d.Body>
</Drawer>
</div>
</template>
}
The drawer renders as role="dialog" with tabindex="0" and aria-modal="true", labelled
by aria-labelledby pointing at the id yielded as headerId — which <d.Header> applies.
aria-labelledby is only rendered while a Header is actually on the page, so a drawer
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.
<Drawer @isOpen={{this.isOpen}} @onClose={{this.close}} aria-label="Filters" as |d|>
<d.Body>Filter controls</d.Body>
</Drawer>
If you label the drawer with a heading of your own rather than <d.Header>, pass
aria-labelledby yourself — putting the yielded headerId on a heading does not label the
dialog by itself, because nothing points at it:
<Drawer @isOpen={{this.isOpen}} @onClose={{this.close}} aria-labelledby={{this.titleId}} as |d|>
<h2 id={{this.titleId}}>My Title</h2>
<d.Body>My Content</d.Body>
</Drawer>
A drawer with no accessible name — no Header, aria-label, or aria-labelledby — is
invalid. Frontile reports this during development with the warning id
frontile.drawer.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 drawer 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 drawer, 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 drawer needs at least one focusable element inside it, or the focus trap has nowhere to
put focus. @allowClosing={{false}} disables Escape, backdrop click and the close button at
once, leaving a keyboard user no way out — the Non-Dismissible example above pairs it with
explicit footer actions for that reason.
Under prefers-reduced-motion: reduce, the drawer fades in place instead of sliding.
Note that @placement is purely visual: a drawer sliding in from the left is announced no
differently from one on the right, and nothing about the placement reaches assistive
technology. Frontile also does not set aria-describedby.
Element: HTMLDivElement
| 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. |
allowDragToClose
|
boolean
|
true for `top`/`bottom`, false for `left`/`right`
|
Enables the drag-to-close gesture and its grab handle. Omit it and the gesture is on for |
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' | 'title' | 'icon' | 'description' | 'closeButton' | 'headerCloseButton' | 'headerContent' | 'headerActions' | 'dragHandle' | 'dragHandleBar'>
|
- | Class names for each slot of the component, merged with the theme's. |
closeButtonSize
|
enum
|
'lg'
|
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 |
onClose
|
function
|
- | A function that will be called when closed |
onOpen
|
function
|
- | A function that will be called when opened |
placement
|
enum
|
'right'
|
The Drawer can appear from any side of the screen. The 'placement' option allows to choose where it appears from. |
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
|
'md'
|
The Drawer 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--slide-from-[placement]'}
|
The transition to be used in the Drawer. |
transitionDuration
|
number
|
200
|
Duration of the animation |
variant
|
enum
|
'sectioned'
|
The Drawer visual variant.
|
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- |