Frontile

Drawer

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

import { Drawer } from 'frontile';

Usage

Basic Drawer

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

@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>
}

Header actions

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>
}

Placement

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>
}

Size

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>
}

Drag to close

@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>
}

Different Backdrop Types

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>
}

Close Button Control

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>
}

Non-Dismissible Drawer

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>
}

Drawers that start open

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.

Anatomy

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.

Patterns

Form in Drawer

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>
}

Accessibility

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.

API

Drawer

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.
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 top and bottom placements and off for left and right. Pass true to opt a side drawer in, or false to turn it off entirely. Always off when allowClosing is 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' | '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) 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--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.

sectioned gives the drawer a black header band, a distinct body surface and a solid footer. flat keeps every region on the modal surface.

Blocks

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