LOKE Design System
Lib

DismissableLayer

A flexible utility component for building dismissable overlays, modals, and popovers with fine‑grained outside‑interaction control.

"use client";

DismissableLayer

DismissableLayer helps you build overlays (modals, drawers, popovers, menus) that should close when a user interacts outside of them or presses Escape. It provides fine‑grained hooks for pointer/focus interactions outside the layer, supports disabling outside pointer events, and composes well when layers are nested.

Use DismissableLayer whenever you need “click outside to close” or “press Escape to close” behavior. For regions inside a layer that must not trigger dismissal, wrap them with DismissableLayerBranch.


Features

  • Dismiss on click/tap outside or on Escape
  • Optional disabling of outside pointer events
  • Customizable hooks for outside pointer and focus events
  • Composable/nestable — multiple layers can coexist
  • Branches that do not trigger dismissal
  • Works with portals and layered UIs

API

DismissableLayer props

Prop

Type

DismissableLayerBranch props

Prop

Type

Use DismissableLayerBranch for nested interactive regions (e.g., a button inside a popover that should not dismiss the popover when clicked).


Usage

Basic

import * as React from 'react';
import { Button } from '@loke/design-system/button';
import { DismissableLayer } from '@loke/ui/dismissable-layer';

export function BasicExample() {
  const [open, setOpen] = React.useState(false);

  return (
    <div className="relative h-[300px] w-full bg-muted p-4">
      <Button onClick={() => setOpen(true)}>Open Layer</Button>

      {open ? (
        <DismissableLayer
          className="absolute left-20 top-20 rounded-md border bg-card p-4 shadow-lg"
          onDismiss={() => setOpen(false)}
        >
          <h2 className="mb-2 font-semibold text-lg">Dismissable Layer</h2>
          <p className="text-sm text-muted-foreground">
            Click outside or press Escape to dismiss.
          </p>
        </DismissableLayer>
      ) : null}
    </div>
  );
}

Disable outside pointer events

export function DisabledOutsidePointerEventsExample() {
  const [open, setOpen] = React.useState(false);

  return (
    <div className="relative h-[300px] w-full bg-muted p-4">
      <Button onClick={() => setOpen(true)}>Open Layer</Button>

      {open ? (
        <DismissableLayer
          disableOutsidePointerEvents
          className="absolute left-20 top-20 rounded-md border bg-card p-4 shadow-lg"
          onDismiss={() => setOpen(false)}
        >
          <h2 className="mb-2 font-semibold text-lg">
            Outside pointer events are disabled
          </h2>
          <p className="text-sm text-muted-foreground">
            Clicking outside will dismiss this layer, and outside elements will not
            receive pointer events until it’s closed.
          </p>
        </DismissableLayer>
      ) : null}
    </div>
  );
}

With a Branch (non‑dismissable region)

export function WithBranchExample() {
  const [open, setOpen] = React.useState(false);

  return (
    <div className="relative h-[300px] w-full bg-muted p-4">
      <Button onClick={() => setOpen(true)}>Open Layer</Button>

      {open ? (
        <DismissableLayer
          className="absolute left-20 top-20 rounded-md border bg-card p-4 shadow-lg"
          onDismiss={() => setOpen(false)}
        >
          <h2 className="mb-2 font-semibold text-lg">Layer with Branch</h2>
          <p className="text-sm text-muted-foreground">
            Actions inside the Branch won’t dismiss the layer.
          </p>

          <DismissableLayerBranch className="mt-4">
            <Button onClick={() => alert('Branch clicked')}>Click me</Button>
          </DismissableLayerBranch>
        </DismissableLayer>
      ) : null}
    </div>
  );
}

Nested layers

export function NestedLayersExample() {
  const [outerOpen, setOuterOpen] = React.useState(false);
  const [innerOpen, setInnerOpen] = React.useState(false);

  return (
    <div className="relative h-[400px] w-full bg-muted p-4">
      <Button onClick={() => setOuterOpen(true)}>Open Outer Layer</Button>

      {outerOpen ? (
        <DismissableLayer
          className="absolute left-20 top-20 rounded-md border bg-card p-4 shadow-lg"
          onDismiss={() => setOuterOpen(false)}
        >
          <h2 className="mb-2 font-semibold text-lg">Outer Layer</h2>
          <p className="text-sm text-muted-foreground">
            Click below to open an inner layer.
          </p>

          <Button className="mt-4" onClick={() => setInnerOpen(true)}>
            Open Inner Layer
          </Button>

          {innerOpen ? (
            <DismissableLayer
              className="absolute left-10 top-10 rounded-md border bg-muted p-4 shadow-lg"
              onDismiss={() => setInnerOpen(false)}
            >
              <h3 className="mb-2 font-semibold">Inner Layer</h3>
              <p className="text-sm text-muted-foreground">
                Interact outside to dismiss this layer first.
              </p>
            </DismissableLayer>
          ) : null}
        </DismissableLayer>
      ) : null}
    </div>
  );
}

Advanced handlers

You can customize dismissal logic by preventing the default on outside interactions:

<DismissableLayer
  onPointerDownOutside={(event) => {
    const original = event.detail.originalEvent;
    // Example: don’t dismiss if click is on a specific element
    if ((original.target as HTMLElement)?.closest('[data-keep-open]')) {
      event.preventDefault();
    }
  }}
  onInteractOutside={(event) => {
    // Generic “outside interaction” handler — runs for pointer or focus events
    // event.preventDefault() here will also prevent dismissal
  }}
  onDismiss={() => setOpen(false)}
/>
  • onPointerDownOutside and onFocusOutside receive a custom event whose detail.originalEvent contains the native event.
  • Call event.preventDefault() to stop dismissal.

Accessibility

  • Escape key handling: built‑in onEscapeKeyDown allows keyboard users to close layers.
  • Focus management: pair with focus management utilities (e.g., focus trap and restore) for fully accessible modals/dialogs.
  • Pointer event disabling: when disableOutsidePointerEvents is true, outside content won’t interact until the layer is closed — this can mirror modal behavior.

Best practices

  • Keep actions that shouldn’t dismiss the layer inside a DismissableLayerBranch.
  • Use disableOutsidePointerEvents for modal-like experiences.
  • For accessible dialogs, combine DismissableLayer with proper focus trapping and ARIA labelling (e.g., role="dialog", aria-labelledby).
  • When nesting, ensure the inner layer dismisses first — the component already accounts for layering order.