Layer

PreviousNext

A full-screen dialog that slides over the page, with a header holding a heading and up to two round buttons, and a scrollable body

'use client';

import { useState } from 'react';
import { Layer } from '@/ui/Layer';
import { Button } from '@/ui/buttons/Button';

export function LayerDemo() {
  const [isOpen, setIsOpen] = useState(false);
  const close = () => setIsOpen(false);

  return (
    <div>
      <Button onClick={() => setIsOpen(true)}>Open Layer</Button>
      <Layer
        heading="Your stay"
        isOpen={isOpen}
        onClose={close}
        rightButton={{ label: 'Close', icon: 'CrossDefault', onClick: close }}
      >
        <div className="space-y-20 p-20">
          <p>
            A Layer covers the whole screen and slides in from the side. Press Escape or use the
            close button to dismiss it.
          </p>
          <p>
            Its body scrolls on its own, below the header, while the page behind it stays locked.
          </p>
        </div>
      </Layer>
    </div>
  );
}

Installation

npx shadcn@latest add https://develop.trident-ui.pro.clubmed/r/layer.json

Usage

'use client';
 
import { useState } from 'react';
import { Layer } from '@/ui/Layer';
import { Button } from '@/ui/buttons/Button';
 
export function Component() {
  const [isOpen, setIsOpen] = useState(false);
  const close = () => setIsOpen(false);
 
  return (
    <>
      <Button onClick={() => setIsOpen(true)}>Open Layer</Button>
      <Layer
        heading="Your stay"
        isOpen={isOpen}
        onClose={close}
        rightButton={{ label: 'Close', icon: 'CrossDefault', onClick: close }}
      >
        <p className="p-20">Layer content</p>
      </Layer>
    </>
  );
}

Header buttons

The header holds up to two round buttons: leftButton is pinned to the inline start (a "back" arrow, for a step-by-step flow) and rightButton to the inline end (a "close" cross). Each one takes an icon, an accessible label and an onClick handler.

'use client';

import { useState } from 'react';
import { Layer } from '@/ui/Layer';
import { Button } from '@/ui/buttons/Button';

export function LayerButtonsDemo() {
  const [isOpen, setIsOpen] = useState(false);
  const [step, setStep] = useState(1);

  const close = () => {
    setIsOpen(false);
    setStep(1);
  };

  return (
    <div>
      <Button onClick={() => setIsOpen(true)}>Book an activity</Button>
      <Layer
        heading={`Step ${step} of 2`}
        isOpen={isOpen}
        onClose={close}
        leftButton={
          step > 1
            ? { label: 'Previous step', icon: 'ArrowDefaultLeft', onClick: () => setStep(1) }
            : undefined
        }
        rightButton={{ label: 'Close', icon: 'CrossDefault', onClick: close }}
      >
        <div className="space-y-20 p-20">
          {step === 1 ? (
            <>
              <p>Choose an activity for your stay.</p>
              <Button onClick={() => setStep(2)}>Continue</Button>
            </>
          ) : (
            <>
              <p>Pick a time slot. The back arrow returns to the previous step.</p>
              <Button onClick={close}>Confirm</Button>
            </>
          )}
        </div>
      </Layer>
    </div>
  );
}

Scale from an element

With animation="scale", the layer grows from the center of the origin element, and shrinks back to it when it closes. Without origin, it scales from the center of the screen.

'use client';

import { useRef, useState } from 'react';
import { Layer } from '@/ui/Layer';
import { Button } from '@/ui/buttons/Button';

export function LayerScaleDemo() {
  const [isOpen, setIsOpen] = useState(false);
  const trigger = useRef<HTMLButtonElement>(null);
  const close = () => setIsOpen(false);

  return (
    <div>
      <Button ref={trigger} onClick={() => setIsOpen(true)} icon="ArrowDefaultRight">
        See the gallery
      </Button>
      <Layer
        animation="scale"
        origin={trigger}
        heading="Gallery"
        isOpen={isOpen}
        onClose={close}
        rightButton={{ label: 'Close', icon: 'CrossDefault', onClick: close }}
      >
        <div className="grid grid-cols-2 gap-8 p-20 md:grid-cols-3">
          {Array.from({ length: 9 }, (_, index) => (
            <div key={index} className="bg-lightSand rounded-16 aspect-square" />
          ))}
        </div>
      </Layer>
    </div>
  );
}

Above the mobile keyboard

On iOS Safari, the keyboard shrinks the visual viewport but not the layout one, so a position: fixed layer ends up partly behind the keyboard. pinToVisualViewport sizes and positions the layer on the visual viewport, and follows it while the keyboard opens, closes or the page zooms. Use it for layers built around a text field, like a search.

'use client';

import { useState } from 'react';
import { Layer } from '@/ui/Layer';
import { Button } from '@/ui/buttons/Button';

export function LayerPinnedDemo() {
  const [isOpen, setIsOpen] = useState(false);
  const close = () => setIsOpen(false);

  return (
    <div>
      <Button onClick={() => setIsOpen(true)} icon="Search">
        Search a resort
      </Button>
      <Layer
        heading="Search"
        isOpen={isOpen}
        onClose={close}
        pinToVisualViewport
        rightButton={{ label: 'Close', icon: 'CrossDefault', onClick: close }}
      >
        <div className="p-20">
          <input
            aria-label="Destination"
            className="w-full rounded-pill border border-black px-20 py-12"
            placeholder="Where do you want to go?"
          />
        </div>
      </Layer>
    </div>
  );
}

Stacked layers

A layer can open on top of another. Only the top one traps the focus and reacts to Escape: closing it gives the focus back to the layer below. Give the nested layer a lower hLevel when it belongs to the same document outline.

'use client';

import { useState } from 'react';
import { Layer } from '@/ui/Layer';
import { Button } from '@/ui/buttons/Button';

export function LayerNestedDemo() {
  const [isFiltersOpen, setIsFiltersOpen] = useState(false);
  const [isDatesOpen, setIsDatesOpen] = useState(false);

  const closeFilters = () => setIsFiltersOpen(false);
  const closeDates = () => setIsDatesOpen(false);

  return (
    <div>
      <Button onClick={() => setIsFiltersOpen(true)}>Filters</Button>
      <Layer
        heading="Filters"
        isOpen={isFiltersOpen}
        onClose={closeFilters}
        rightButton={{ label: 'Close', icon: 'CrossDefault', onClick: closeFilters }}
      >
        <div className="space-y-20 p-20">
          <p>A second layer can open on top of this one.</p>
          <Button onClick={() => setIsDatesOpen(true)}>Choose dates</Button>
        </div>
      </Layer>
      <Layer
        heading="Dates"
        hLevel={2}
        isOpen={isDatesOpen}
        onClose={closeDates}
        leftButton={{ label: 'Back to filters', icon: 'ArrowDefaultLeft', onClick: closeDates }}
      >
        <div className="p-20">
          <p>Escape closes this layer only, and returns to the filters.</p>
        </div>
      </Layer>
    </div>
  );
}

Portal target

The layer renders in a portal, at the end of the body, so no overflow: hidden or transform ancestor can clip it. By default it goes in the #portal element, created on demand. Pass the id of another element as target to control where layers land in the page, for example to stack them above or below the content of other portals (a Popin renders in #backdrop).

// app shell: an element placed after the other portals, so layers stack on top of them
<body>
  <div id="root" />
  <div id="backdrop" />
  <div id="layers" />
</body>
 
// anywhere in the app
<Layer heading="Filters" isOpen={isOpen} onClose={close} target="layers">
  ...
</Layer>

When no element has that id, it is created at the end of the body the first time a layer renders in it.

With useLayer

useLayer holds the open state, and calls optional onOpen / onClose callbacks. Its open and close functions keep the same identity across renders, even with inline callbacks, so they can be passed to memoized children.

'use client';

import { Layer } from '@/ui/Layer';
import { Button } from '@/ui/buttons/Button';
import { useLayer } from '@/ui/hooks/useLayer';

export function LayerUseLayerDemo() {
  const { close, isOpen, open } = useLayer({
    onClose: () => console.log('Layer closed'),
    onOpen: () => console.log('Layer opened'),
  });

  return (
    <div>
      <Button onClick={open}>Open with useLayer</Button>
      <Layer
        heading="Driven by useLayer"
        isOpen={isOpen}
        onClose={close}
        rightButton={{ label: 'Close', icon: 'CrossDefault', onClick: close }}
      >
        <div className="p-20">
          <p>`open` and `close` keep the same identity across renders.</p>
        </div>
      </Layer>
    </div>
  );
}

API Reference

Layer

Extra props (id, data-*, aria-*, className, style...) are spread on the dialog element.

PropTypeDefaultDescription
isOpenboolean(required)Whether the layer is displayed. Opening and closing are animated.
onClose() => void(required)Called on Escape and on a click outside the layer. It is not wired to the header buttons: pass it to them.
headingReactNode(required)Content of the header heading. It is also the accessible name of the dialog.
hLevel1 | 2 | 3 | 4 | 5 | 61Level of the heading tag.
leftButtonLayerHeaderButtonPropsundefinedRound button pinned to the inline start of the header.
rightButtonLayerHeaderButtonPropsundefinedRound button pinned to the inline end of the header.
animation'slide' | 'scale''slide'slide comes in from the inline end, scale grows from origin.
originRefObject<HTMLElement | null>undefinedElement the scale animation grows from and shrinks back to. Ignored by slide.
pinToVisualViewportbooleanfalsePins the layer to the visual viewport, so the iOS keyboard does not push it off-screen.
hasFocusTrapbooleantrueTraps the focus, locks the page scroll, hides the rest of the page from assistive technologies, and enables Escape / outside click.
hasPinchZoombooleantrueKeeps pinch zoom available while the page scroll is locked.
targetstring'portal'Id of the element the layer is rendered in. It is created at the end of the body when missing.
childrenReactNodeundefinedBody of the layer. It scrolls on its own, below the header.

LayerHeaderButtonProps

Any <button> attribute and Button prop is forwarded, except variant and theme: header buttons are always black outlined circles.

PropTypeDefaultDescription
iconIconicNames(required)Icon of the round button.
labelstring(required)Accessible name of the button (aria-label).
onClickMouseEventHandler<HTMLButtonElement>(required)Click handler.

useLayer

OptionTypeDefaultDescription
openedbooleanfalseInitial open state.
onOpen() => voidundefinedCalled when open() is called.
onClose() => voidundefinedCalled when close() is called.

It returns { isOpen, open, close }.

Behaviour

Transitions

The layer is mounted when isOpen becomes true, and unmounted once its 600ms exit transition is over. While it closes, it is inert, so it can not be focused or clicked anymore.

Only translate, scale and opacity are transitioned (Tailwind 4 animates these standalone properties rather than transform), so a class you add, or the box set by pinToVisualViewport, never animates by accident. The will-change hint that puts the layer on its own compositing layer is only set while it moves: a full-screen layer kept promoted while it is open would hold a large GPU texture for nothing.

Scale origin

The origin element is measured once, when the layer opens, and not on every render: reading its position forces the browser to compute the layout. The page scroll is locked while the layer is open, so that position is still valid when the layer shrinks back to it.

Scroll position

The page scroll is locked while the layer is open. The scroll position is saved when the layer opens and restored after it closes, which works around iOS Safari jumping to the top of the page.

Accessibility

  • The layer is a role="dialog" with aria-modal="true", labelled by its heading.
  • With hasFocusTrap (default), the focus moves into the layer when it opens, can not leave it, and goes back to the previously focused element when it closes. The rest of the page is hidden from assistive technologies.
  • Header buttons are named by their label and described by the heading, so a screen reader announces "Close, Your stay".
  • Pass a node as heading to compose it, like a visual title hidden with aria-hidden next to a more descriptive sr-only text.
  • Escape closes the top layer only.

Notes

  • Controlled only: the layer never closes by itself. onClose must set isOpen to false.
  • Header buttons are not wired to onClose: pass your close handler to rightButton.onClick (or leftButton.onClick).
  • Portal: the layer renders in the target element (#portal by default), appended to the body when missing, so it is not clipped by an overflow: hidden ancestor. The container is resolved when the layer first renders: changing target afterwards does not move a mounted layer, give it a new key instead. It renders nothing during server-side rendering.
  • Client Component: Layer is marked 'use client'. It depends on react-focus-on for the focus trap and the scroll lock.
  • Custom classes: className is applied to the dialog, for example lg:start-auto lg:w-1/2 to turn the layer into a side panel on large screens.