---
title: Layer
description: 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
---

```tsx
'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>
  );
}
```
<div id="portal" style={{"--spacing":"1px"}} />

## Installation

Using the CLI:

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

Or manually, copy the following code into `/ui/Layer.tsx`:

```tsx
'use client';
import clsx from 'clsx';
import {
  type ComponentProps,
  type CSSProperties,
  type FunctionComponent,
  type ReactNode,
  type RefObject,
  useEffect,
  useId,
  useRef,
  useState,
} from 'react';
import { FocusOn } from 'react-focus-on';

import { useTransitionState } from './hooks/useTransitionState';
import { useVisualViewportBox } from './hooks/useVisualViewportBox';
import { LayerHeader, type LayerHeaderProps } from './LayerHeader';
import { Portal, type PortalProps } from './Portal';

import type { UseLayerOptions } from './hooks/useLayer';

export type { UseLayerOptions };

const ANIMATIONS = {
  scale: {
    from: 'scale-[0.167] opacity-0',
    to: 'scale-100 opacity-100',
  },
  slide: {
    from: 'translate-x-full rtl:-translate-x-full opacity-0',
    to: 'translate-x-0 opacity-100',
  },
} as const;

// must match the `duration-600` of the dialog, so the transition state follows the CSS transition:
// `entering` / `exiting` last as long as it runs, and the dialog unmounts once it is over
const TRANSITION_DURATION = 600;

const getOrigin = (origin: LayerProps['origin']) => {
  if (!origin?.current) {
    return undefined;
  }

  const { x, y, width, height } = origin.current.getBoundingClientRect();
  const positionX = x + width / 2;
  const positionY = y + height / 2;

  return `${positionX}px ${positionY}px`;
};

export interface LayerProps extends ComponentProps<'div'> {
  /**
   * How the layer enters and leaves the screen
   * @default 'slide'
   */
  animation?: keyof typeof ANIMATIONS;
  /**
   * Traps the focus, locks the page scroll and closes on Escape / outside click while open
   * @default true
   */
  hasFocusTrap?: boolean;
  /**
   * Keeps pinch zoom available while the page scroll is locked
   * @default true
   */
  hasPinchZoom?: ComponentProps<typeof FocusOn>['allowPinchZoom'];
  /**
   * Content of the header heading, also used as the accessible name of the dialog
   */
  heading: ReactNode;
  /**
   * Level of the header heading tag
   * @default 1
   */
  hLevel?: LayerHeaderProps['hLevel'];
  /**
   * Whether the layer is displayed
   */
  isOpen: boolean;
  /**
   * Round button pinned to the inline start of the header
   */
  leftButton?: LayerHeaderProps['leftButton'];
  /**
   * Called on Escape or on a click outside the layer
   */
  onClose: () => void;
  /**
   * Element the `scale` animation grows from and shrinks back to
   */
  origin?: RefObject<HTMLElement | null>;
  /**
   * Pins the layer to the visual viewport, so the iOS keyboard does not push it off-screen
   * @default false
   */
  pinToVisualViewport?: boolean;
  /**
   * Round button pinned to the inline end of the header
   */
  rightButton?: LayerHeaderProps['rightButton'];
  /**
   * Id of the element the layer is rendered in, created at the end of the `body` when missing
   * @default 'portal'
   */
  target?: PortalProps['target'];
}

export const Layer: FunctionComponent<LayerProps> = ({
  animation = 'slide',
  children,
  className,
  hasFocusTrap = true,
  hasPinchZoom = true,
  heading,
  hLevel = 1,
  isOpen = false,
  leftButton,
  onClose,
  origin,
  pinToVisualViewport = false,
  rightButton,
  style,
  target = 'portal',
  ...attrs
}) => {
  const headerId = useId();
  const setDialogNode = useVisualViewportBox<HTMLDivElement>();
  const [animationOrigin, setAnimationOrigin] = useState<string>();

  // Store scroll position to prevent iOS WebKit scroll-to-top bug when closing Layer
  const scrollPositionRef = useRef<number>(0);

  const [{ isMounted, status }, toggle] = useTransitionState({
    timeout: TRANSITION_DURATION,
    mountOnEnter: true,
    unmountOnExit: true,
    preEnter: true,
  });

  useEffect(() => {
    // Measure the origin once, when the layer opens: getBoundingClientRect() forces a synchronous
    // layout, so it must not run on every render (each transition step re-renders the layer).
    // The page scroll is locked while the layer is open, so the origin is still valid on close.
    if (isOpen && animation === 'scale') {
      setAnimationOrigin(getOrigin(origin));
    }

    toggle(isOpen);
  }, [animation, isOpen, origin, toggle]);

  useEffect(() => {
    // Capture scroll position when Layer opens (iOS WebKit fix)
    if (isOpen) {
      scrollPositionRef.current = window.scrollY || document.documentElement.scrollTop;
    }
    // Restore scroll position when Layer closes (iOS WebKit fix)
    if (!isOpen && scrollPositionRef.current > 0) {
      // Use requestAnimationFrame to ensure DOM is ready after Layer unmount
      requestAnimationFrame(() => {
        window.scrollTo(0, scrollPositionRef.current);
      });
    }
  }, [isOpen]);

  const out = status === 'preEnter' || status === 'exiting' || status === 'exited';
  const isAnimating = status === 'preEnter' || status === 'entering' || status === 'exiting';

  return (
    <Portal target={target}>
      {isMounted && (
        <FocusOn
          allowPinchZoom={hasPinchZoom}
          enabled={hasFocusTrap && isOpen}
          onClickOutside={onClose}
          onEscapeKey={onClose}
          scrollLock
        >
          <div
            {...attrs}
            aria-labelledby={headerId}
            aria-modal="true"
            className={clsx(
              // Tailwind 4 animates the standalone `translate` and `scale` properties, not `transform`.
              // Listing them (rather than `transition-all`) keeps every other property, like the
              // box written by `pinToVisualViewport`, from being transitioned
              'fixed inset-0 z-50 flex max-h-screen max-w-screen flex-col bg-white transition-[translate,scale,opacity] duration-600',
              'origin-(--layer-origin,center)',
              className,
              {
                // only promote the full-screen layer to its own compositing layer while it moves,
                // instead of holding that GPU memory for as long as it stays open
                'will-change-[translate,scale,opacity]': isAnimating,
                [ANIMATIONS[animation].from]: out,
                [ANIMATIONS[animation].to]: !out,
              },
            )}
            ref={pinToVisualViewport ? setDialogNode : undefined}
            style={
              animationOrigin
                ? ({ ...style, '--layer-origin': animationOrigin } as CSSProperties)
                : style
            }
            role="dialog"
            inert={isOpen ? undefined : true}
          >
            <LayerHeader
              hLevel={hLevel}
              id={headerId}
              leftButton={leftButton}
              rightButton={rightButton}
            >
              {heading}
            </LayerHeader>
            <div className="relative flex-1 overflow-y-auto overscroll-contain">{children}</div>
          </div>
        </FocusOn>
      )}
    </Portal>
  );
};
```

## Usage

```tsx showLineNumbers
'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.

```tsx
'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.

```tsx
'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.

```tsx
'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.

```tsx
'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`).

```tsx showLineNumbers
// 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.

```tsx
'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.

| Prop                  | Type                             | Default     | Description                                                                                                  |
| --------------------- | -------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------ |
| `isOpen`              | `boolean`                        | (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.   |
| `heading`             | `ReactNode`                      | (required)  | Content of the header heading. It is also the accessible name of the dialog.                                 |
| `hLevel`              | `1 \| 2 \| 3 \| 4 \| 5 \| 6`     | `1`         | Level of the heading tag.                                                                                    |
| `leftButton`          | `LayerHeaderButtonProps`         | `undefined` | Round button pinned to the inline start of the header.                                                       |
| `rightButton`         | `LayerHeaderButtonProps`         | `undefined` | Round button pinned to the inline end of the header.                                                         |
| `animation`           | `'slide' \| 'scale'`             | `'slide'`   | `slide` comes in from the inline end, `scale` grows from `origin`.                                           |
| `origin`              | `RefObject<HTMLElement \| null>` | `undefined` | Element the `scale` animation grows from and shrinks back to. Ignored by `slide`.                            |
| `pinToVisualViewport` | `boolean`                        | `false`     | Pins the layer to the visual viewport, so the iOS keyboard does not push it off-screen.                      |
| `hasFocusTrap`        | `boolean`                        | `true`      | Traps the focus, locks the page scroll, hides the rest of the page from assistive technologies, and enables Escape / outside click. |
| `hasPinchZoom`        | `boolean`                        | `true`      | Keeps pinch zoom available while the page scroll is locked.                                                  |
| `target`              | `string`                         | `'portal'`  | Id of the element the layer is rendered in. It is created at the end of the `body` when missing.             |
| `children`            | `ReactNode`                      | `undefined` | Body 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.

| Prop      | Type                                   | Default    | Description                                       |
| --------- | -------------------------------------- | ---------- | ------------------------------------------------- |
| `icon`    | `IconicNames`                          | (required) | Icon of the round button.                         |
| `label`   | `string`                               | (required) | Accessible name of the button (`aria-label`).     |
| `onClick` | `MouseEventHandler<HTMLButtonElement>` | (required) | Click handler.                                    |

### useLayer

| Option    | Type         | Default     | Description                     |
| --------- | ------------ | ----------- | ------------------------------- |
| `opened`  | `boolean`    | `false`     | Initial open state.             |
| `onOpen`  | `() => void` | `undefined` | Called when `open()` is called. |
| `onClose` | `() => void` | `undefined` | Called 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.
