- Accordion
- Advanced Toast
- AlertMessage
- ArrowButton
- Arrows
- Avatar
- Backdrop
- Basic Toast
- Breadcrumb
- Button
- CardBackground
- Card
- Carousel
- Chapters
- ChatBody
- ChatButton
- ChatHeader
- ChatInput
- ChatMessageActions
- ChatMessageEditor
- ChatMessage
- ChatTypingIndicator
- ChatWindowCsatPrompt
- ChatWindowQuote
- ChatWindow
- CheckboxSelect
- Checkbox
- Checkboxes
- Chip
- ChoiceExpander
- Clickable
- ContentReview
- Countdown
- CriteriaDates
- CriteriaParticipants
- CriteriaTransports
- Date Field
- Datepicker
- Dropdown
- ElasticHeight
- ExpandableCard
- ExpandableText
- Filter
- FloatingLabelTextField
- Form Control
- Form Label
- Frame
- HamburgerIcon
- Heading
- Image
- JourneyCard
- LayerHeader
- Layer
- Link
- Loader
- Number Field
- PageNavigation
- Pagination
- Paragraph
- Password
- Phone Field
- Popin
- Portal
- ProductThumbnail
- Promises
- Prose
- Radio Group
- RadioSelect
- Radio
- Range
- Scrollbar
- Select
- SidebarLayout
- SliderFrame
- SparkleWrapper
- Spinner
- Switch
- Table
- Tabs
- Tag
- TextField
- Toggle
- Tooltip
- ValidationMessage
'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.
| 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"witharia-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
labeland described by the heading, so a screen reader announces "Close, Your stay". - Pass a node as
headingto compose it, like a visual title hidden witharia-hiddennext to a more descriptivesr-onlytext. - Escape closes the top layer only.
Notes
- Controlled only: the layer never closes by itself.
onClosemust setisOpentofalse. - Header buttons are not wired to
onClose: pass your close handler torightButton.onClick(orleftButton.onClick). - Portal: the layer renders in the
targetelement (#portalby default), appended to thebodywhen missing, so it is not clipped by anoverflow: hiddenancestor. The container is resolved when the layer first renders: changingtargetafterwards does not move a mounted layer, give it a newkeyinstead. It renders nothing during server-side rendering. - Client Component: Layer is marked
'use client'. It depends onreact-focus-onfor the focus trap and the scroll lock. - Custom classes:
classNameis applied to the dialog, for examplelg:start-auto lg:w-1/2to turn the layer into a side panel on large screens.