GitHub

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='flex flex-col gap-4'>
      <Button @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 @onPress={{this.toggle}}>
            Cancel
          </Button>
          <Button @intent='primary'>
            Save
          </Button>
        </d.Footer>
      </Drawer>
    </div>
  </template>
}

Different Placements

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='flex flex-col gap-4'>
      <div class='grid grid-cols-2 gap-2'>
        {{#each this.placements as |placement|}}
          <Button @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>
}

Different Sizes

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='flex flex-col gap-4'>
      <div class='grid grid-cols-3 gap-2'>
        {{#each this.sizeOptions as |option|}}
          <Button @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>
}

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='flex flex-col gap-4'>
      <div class='grid grid-cols-2 gap-2'>
        {{#each this.backdropOptions as |option|}}
          <Button @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 @onPress={{this.closeDrawer}}>Close</Button>
        </d.Footer>
      </Drawer>
    </div>
  </template>
}

Close Button Control

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 { 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='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>

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

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='flex flex-col gap-4'>
      <Button @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 @onPress={{this.toggle}}>
            Cancel
          </Button>
          <Button @intent='primary' @onPress={{this.handleSubmit}}>
            Send Message
          </Button>
        </d.Footer>
      </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='flex flex-col gap-4'>
      <Button @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}}%'
                @intent='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 disabled={{true}}>
              Processing...
            </Button>
            <Button @intent='danger' @onPress={{this.forceClose}}>
              Force Close
            </Button>
          {{else}}
            <Button @onPress={{this.toggle}}>
              Cancel
            </Button>
            <Button @intent='primary' @onPress={{this.startProcess}}>
              Start Processing
            </Button>
          {{/if}}
        </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='flex flex-col gap-4'>
      <Button @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>
}

Anatomy

Drawer yields the pieces you assemble it 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 drawer renders as role="dialog" with tabindex="0", labelled by aria-labelledby pointing at the id yielded as headerId — which <d.Header> applies. A drawer with no Header has a dangling label reference. Render a Header, or put headerId on your own heading, so assistive technology has something to announce.

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

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.

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-modal or aria-describedby.

API

Drawer

Element: <span class="hljs-title class_">HTMLDivElement</span>

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

Blocks

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