Slab

Overlays

Drawer

A non-modal dialog for a side panel. The page behind it stays usable.

Element
<slab-drawer>
Built on
<dialog> (non-modal)
As Markdown
drawer.md

Example

Here's a styled version, <slab-styled-drawer>. It's not in the package — it's an example of what you can build with the headless element. Copy it and make it yours at the bottom of this page. Switch to "As it ships" to see the same markup with the headless element and no CSS at all. That's your starting point.

Styled example As it ships

14 Alder Lane. A 1,600 square foot slab, 4 inches thick, on a gravel base.

Poured Friday. Curing until the 23rd.

HTML
<button class="styled-button" type="button">Site details</button>

<slab-styled-drawer id="drawer-example" label="Lot 14">
  <p>14 Alder Lane. A 1,600 square foot slab, 4 inches thick, on a gravel base.</p>
  <p>Poured Friday. Curing until the 23rd.</p>

  <form method="dialog">
    <button class="styled-button">Close</button>
  </form>
</slab-styled-drawer>

14 Alder Lane. A 1,600 square foot slab, 4 inches thick, on a gravel base.

Poured Friday. Curing until the 23rd.

HTML
<button class="styled-button" type="button">Site details</button>

<slab-drawer id="drawer-example-as-it-ships" label="Lot 14">
  <p>14 Alder Lane. A 1,600 square foot slab, 4 inches thick, on a gravel base.</p>
  <p>Poured Friday. Curing until the 23rd.</p>

  <form method="dialog">
    <button class="styled-button">Close</button>
  </form>
</slab-drawer>

Usage

Install
pnpm add slab-elements lit
HTML
<script type="module">
  import 'slab-elements/drawer.js';
</script>

<slab-drawer label="Filters">
  …

  <form method="dialog">
    <button>Done</button>
  </form>
</slab-drawer>

Keyboard

Key What it does
Escape Closes the drawer, unless closedby is “none”.
Tab Moves focus on through the drawer and out to the page. There’s no focus trap.

Behavior

A non-modal dialog with no visual styles. It's Dialog, opened with show() instead of showModal(), so the page behind it stays interactive: there's no focus trap, no ::backdrop, and nothing is made inert.

Everything else is Dialog's: open, returnValue, close(returnValue), a slotted <form method="dialog">, the label, focus moving into it when it opens, and "cancel" and "close" from the host. Focus goes back to where it was when it closes, which the <dialog> does itself.

A non-modal <dialog> ignores Escape unless it has closedby, so closedby defaults to "closerequest" here. Escape closes it, and "cancel" can still be canceled to keep it open. Set "none" to turn that off, or "any" to also close it on a click outside it.

Which edge it's anchored to, and how it gets there, is how it looks. Position it through ::part(dialog).

API

<slab-drawer>

Attributes
Name Type Default Description
closedby 'any' | 'closerequest' | 'none' Passed to the <dialog>. "any" closes it on a click outside it where the browser supports that. Browsers that don't ignore it.
hide-label boolean false
label string
open boolean false
version string
Properties
Name Type Description
returnValue string The <dialog>'s, so close(returnValue) and a <form method="dialog"> set it the way they do natively.
Methods
Signature Description
close(returnValue?: string): void
show(): void
Events
Name Type Description
cancel Event
close Event
Slots
Name Type Description
label Element | string
(default) Element | string
CSS parts
Name Description
dialog The native dialog. A modal one's backdrop is its ::backdrop
label The label

Copy and own it

<slab-styled-drawer> isn't in the package. It's an example — copy it into your project and change whatever you want. Under the hood, it's just the headless element, subclassed, with styles set. That's it. Keyboard, focus, ARIA, forms, and events all come from the headless element, and the styles only reach in through ::part() and :state(), same as yours would.

To use it, copy these files, swap the tag in @customElement() for one of your own, and import it. tokens.styles.ts holds the colors and fonts every styled example shares, so you only need it once.

drawer.ts
import { customElement } from 'lit/decorators.js';
import Drawer from 'slab-elements/drawer.js';
import dialog from './dialog.styles.js';
import styles from './drawer.styles.js';
import tokens from './tokens.styles.js';

declare global {
  interface HTMLElementTagNameMap {
    'slab-styled-drawer': StyledDrawer;
  }
}

/**
 * Drawer, styled: Dialog's header and edges, as a full-height panel on the
 * page's end edge.
 */
@customElement('slab-styled-drawer')
export default class StyledDrawer extends Drawer {
  static override styles = [tokens, dialog, styles];
}
dialog.styles.ts
import { css } from 'lit';

export default css`
  /*
    A closed dialog isn't displayed, so the animation runs again each time it
    opens.
  */
  [part~='dialog'] {
    animation: rise 200ms ease-out;
    background-color: var(--paper);
    border: 3px solid var(--ink);
    box-shadow: 10px 10px 0 var(--ember);
    box-sizing: border-box;
    color: var(--ink);
    font-family: var(--font-body);
    inline-size: min(32rem, 100% - 2rem);
    line-height: 1.5;
    padding: 0 1.5rem 1.5rem;

    &::backdrop {
      animation: fade 200ms ease-out;

      /*
        Not a token. A backdrop doesn't inherit custom properties from the
        dialog in every browser, but it does take its color scheme.
      */
      background-color: light-dark(rgb(28 28 28 / 70%), rgb(0 0 0 / 75%));
    }
  }

  /*
    The header. It's pulled out to the dialog's edges, which its padding would
    otherwise keep it from.
  */
  [part~='label'] {
    /*
      Ink in the light. In the dark, ink is the light color, so the header is
      the raised surface instead, rather than a bright bar.
    */
    background-color: light-dark(var(--ink), var(--chalk));
    border-block-end: 6px solid var(--ember);
    color: light-dark(var(--paper), var(--ink));
    font-family: var(--font-display);
    font-size: 1.5rem;
    font-weight: 600;
    margin-block-end: 1.25rem;
    margin-inline: -1.5rem;
    padding: 1rem 1.5rem;
  }

  @keyframes rise {
    from {
      opacity: 0;
      translate: 0 1rem;
    }
  }

  @keyframes fade {
    from {
      opacity: 0;
    }
  }

  @media (prefers-reduced-motion: reduce) {
    [part~='dialog'],
    [part~='dialog']::backdrop {
      animation: none;
    }
  }
`;
drawer.styles.ts
import { css } from 'lit';

/*
  Dialog's styles, then these, which move it to the page's end edge at full
  height. A Drawer is non-modal, so there's no backdrop, and the shadow falls
  across the page instead.
*/
export default css`
  [part~='dialog'] {
    animation: slide 200ms ease-out;
    block-size: 100dvh;
    border-block: 0;
    border-inline-end: 0;
    box-shadow: -8px 0 0 var(--ember);
    inline-size: min(26rem, 100% - 2rem);
    inset-block: 0;
    inset-inline: auto 0;
    margin: 0;
    max-block-size: none;
    position: fixed;
  }

  @keyframes slide {
    from {
      opacity: 0;
      translate: 1.5rem 0;
    }
  }

  @media (prefers-reduced-motion: reduce) {
    [part~='dialog'] {
      animation: none;
    }
  }
`;
tokens.styles.ts
import { css } from 'lit';

/*
  The styled components' palette and type, taken from the logo: ink for the
  courses, ember for the base, paper behind them.

  Each is a short alias for a "--slab-styled-*" property, with the default as
  its fallback. Retheming is "--slab-styled-ember" and the rest on ":root",
  which every shadow root inherits. Were the defaults declared under those
  names here instead, each host's own declaration would shadow the page's.

  Each default is "light-dark()", so the components follow the page's
  "color-scheme": "dark" for dark, "light" for light, and "light dark" to
  follow the operating system. Ink and paper trade places in the dark, so
  ink is always the text and paper always what's behind it. Contrast is given
  as light, then dark.
*/
export default css`
  :host {
    /* Secondary text. 6.8:1 and 6.9:1 on paper. */
    --ash: var(--slab-styled-ash, light-dark(#5e5a55, #a8a19a));
    --border: 2px solid var(--ink);

    /* Behind disabled and read-only controls, and hovered rows. */
    --chalk: var(--slab-styled-chalk, light-dark(#f4f1ec, #2b2926));

    /*
      The logo's orange, brighter in the dark. 4.25:1 and 5.2:1 on paper is
      enough for borders, fills and large text, but not always for small text,
      which takes "--ember-deep".
    */
    --ember: var(--slab-styled-ember, light-dark(#d9492a, #ec5f3b));

    /*
      Orange for small text on paper, and for paper text on it. 5.7:1 and
      7.4:1.
    */
    --ember-deep: var(--slab-styled-ember-deep, light-dark(#b3381a, #ff8566));
    --font-body: var(
      --slab-styled-font-body,
      'IBM Plex Sans',
      system-ui,
      sans-serif
    );
    --font-display: var(
      --slab-styled-font-display,
      'Zilla Slab',
      rockwell,
      'Roboto Slab',
      serif
    );
    --font-mono: var(
      --slab-styled-font-mono,
      'IBM Plex Mono',
      ui-monospace,
      monospace
    );
    --ink: var(--slab-styled-ink, light-dark(#1c1c1c, #f4f1ec));
    --paper: var(--slab-styled-paper, light-dark(#fff, #1a1918));

    /* The hard, offset shadow a raised surface casts. */
    --shadow: 4px 4px 0 var(--ink);
  }
`;