Slideover
For details that deserve a whole panel, but not a whole page.
<Slideover open={isOpen} onClose={setIsOpen}>
<div className="flex items-start justify-between gap-4">
<SlideoverTitle>pay.arthurmonney.me</SlideoverTitle>
<button type="button" onClick={() => setIsOpen(false)}>{/* close icon */}</button>
</div>
<SlideoverDescription>
DNS configuration can vary across providers. Refer to the{' '}
<TextLink href="#">provider documentation</TextLink> for more information.
</SlideoverDescription>
<SlideoverBody>
<Subheading>Configuration</Subheading>
<DescriptionList className="mt-2">
<DescriptionTerm>Domain</DescriptionTerm>
<DescriptionDetails>pay.arthurmonney.me</DescriptionDetails>
<DescriptionTerm>Wildcard</DescriptionTerm>
<DescriptionDetails>Disabled</DescriptionDetails>
</DescriptionList>
<div className="mt-8 flex items-center justify-between gap-4">
<Subheading>DNS records</Subheading>
<Button outline>Refresh</Button>
</div>
<Table className="mt-2">
<TableHead>
<TableRow>
<TableHeader>Type</TableHeader>
<TableHeader>Name</TableHeader>
<TableHeader>Value</TableHeader>
<TableHeader>
<span className="sr-only">Status</span>
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell>A</TableCell>
<TableCell className="font-mono">pay.arthurmonney.me</TableCell>
<TableCell className="font-mono">103.133.1.1</TableCell>
<TableCell>Verifying</TableCell>
</TableRow>
</TableBody>
</Table>
</SlideoverBody>
</Slideover><Slideover :open="isOpen" @close="isOpen = false">
<div class="flex items-start justify-between gap-4">
<SlideoverTitle>pay.arthurmonney.me</SlideoverTitle>
<button type="button" @click="isOpen = false"><!-- close icon --></button>
</div>
<SlideoverDescription>
DNS configuration can vary across providers. Refer to the
<TextLink href="#">provider documentation</TextLink> for more information.
</SlideoverDescription>
<SlideoverBody>
<Subheading>Configuration</Subheading>
<DescriptionList class="mt-2">
<DescriptionTerm>Domain</DescriptionTerm>
<DescriptionDetails>pay.arthurmonney.me</DescriptionDetails>
<DescriptionTerm>Wildcard</DescriptionTerm>
<DescriptionDetails>Disabled</DescriptionDetails>
</DescriptionList>
<div class="mt-8 flex items-center justify-between gap-4">
<Subheading>DNS records</Subheading>
<Button outline>Refresh</Button>
</div>
<Table class="mt-2">
<TableHead>
<TableRow>
<TableHeader>Type</TableHeader>
<TableHeader>Name</TableHeader>
<TableHeader>Value</TableHeader>
<TableHeader><span class="sr-only">Status</span></TableHeader>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell>A</TableCell>
<TableCell class="font-mono">pay.arthurmonney.me</TableCell>
<TableCell class="font-mono">103.133.1.1</TableCell>
<TableCell>
<span class="flex items-center gap-1.5 text-blue-600 dark:text-blue-400">
<span class="relative size-4 shrink-0">
<svg fill="none" viewBox="0 0 16 16" class="text-zinc-950 dark:text-white">
<path stroke="currentColor" stroke-width="2" stroke-dasharray="3 3" stroke-opacity="0.24" d="M8 14A6 6 0 108 2a6 6 0 000 12z" />
</svg>
<svg fill="none" viewBox="0 0 16 16" class="absolute inset-0 size-full animate-spin text-blue-600">
<path stroke="currentColor" stroke-width="2" d="M11.346 3.02a6.002 6.002 0 012.54 6.155" />
</svg>
</span>
Verifying
</span>
</TableCell>
</TableRow>
</TableBody>
</Table>
</SlideoverBody>
</Slideover>Installation
Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.
'use client'
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import type React from 'react'
import { useModalStackPosition } from './modal-stack'
import { Text } from './text'
const sizes = {
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
'3xl': 'sm:max-w-3xl',
}
export function Slideover({
size = 'xl',
dismissible = true,
onClose,
className,
children,
...props
}: {
size?: keyof typeof sizes
dismissible?: boolean
className?: string
children: React.ReactNode
} & Omit<Headless.DialogProps, 'as' | 'className'>) {
let { depth, cover, isBase } = useModalStackPosition('slideover')
return (
<Headless.Dialog {...props} onClose={dismissible ? onClose : () => {}} inert={depth > 0 || undefined}>
{isBase && (
<Headless.DialogBackdrop
transition
className="fixed inset-0 bg-zinc-950/25 transition duration-200 motion-reduce:duration-0 focus:outline-0 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in dark:bg-zinc-950/50"
/>
)}
<div className="fixed inset-0 overflow-hidden">
<div
style={{ '--stack-depth': depth, '--stack-cover': cover } as React.CSSProperties}
className={clsx(
sizes[size],
'fixed top-2 right-2 bottom-[max(calc(var(--spacing)*2),env(safe-area-inset-bottom))] left-2 sm:left-auto sm:w-full',
'origin-left transform-gpu transition-[translate,scale] duration-200 ease-out motion-reduce:transition-none sm:translate-x-[calc(var(--stack-depth)*-1rem)] sm:scale-[calc(1-0.04*var(--stack-depth))]',
'after:pointer-events-none after:absolute after:inset-0 after:rounded-2xl after:bg-zinc-950/30 after:opacity-[calc(var(--stack-cover,0)*0.5)] after:transition-opacity after:duration-200 motion-reduce:after:transition-none dark:after:bg-zinc-950/50'
)}
>
<Headless.DialogPanel
transition
className={clsx(
className,
'flex h-full w-full flex-col overflow-y-auto rounded-2xl p-(--gutter) [--gutter:--spacing(6)] sm:[--gutter:--spacing(8)]',
'bg-white dark:bg-zinc-900',
'shadow-lg ring-1 ring-zinc-950/10 dark:ring-white/10 forced-colors:outline',
'transition duration-200 motion-reduce:duration-0 will-change-transform data-closed:translate-x-full data-enter:ease-out data-leave:ease-in'
)}
>
{children}
</Headless.DialogPanel>
</div>
</div>
</Headless.Dialog>
)
}
export function SlideoverTitle({
className,
...props
}: { className?: string } & Omit<Headless.DialogTitleProps, 'as' | 'className'>) {
return (
<Headless.DialogTitle
{...props}
className={clsx(className, 'font-semibold text-zinc-950 dark:text-white text-lg/6 sm:text-base/6 text-balance')}
/>
)
}
export function SlideoverDescription({
className,
...props
}: { className?: string } & Omit<Headless.DescriptionProps<typeof Text>, 'as' | 'className'>) {
return <Headless.Description as={Text} {...props} className={clsx(className, 'mt-2 text-pretty')} />
}
export function SlideoverBody({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
return <div {...props} className={clsx(className, 'mt-6 flex-1')} />
}
export function SlideoverActions({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
return (
<div
{...props}
className={clsx(
className,
'mt-8 flex shrink-0 flex-col-reverse sm:flex-row items-center justify-end gap-3 *:w-full sm:*:w-auto'
)}
/>
)
}<script lang="ts">
import type { InjectionKey } from 'vue'
import type { DialogIds } from './dialog.vue'
export const SlideoverIdsKey: InjectionKey<DialogIds> = Symbol('TrelundSlideoverIds')
</script>
<script setup lang="ts">
import { hideOthers } from 'aria-hidden'
import { FocusScope } from 'reka-ui'
import { onBeforeUnmount, provide, ref, useId, watch } from 'vue'
import { focusAutofocusElement, isEscapeForModal } from './dialog.vue'
import { useModalStackPosition } from './modal-stack'
import { useScrollLock } from './use-scroll-lock'
const sizes = {
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
'3xl': 'sm:max-w-3xl',
}
const props = withDefaults(
defineProps<{
open: boolean
size?: keyof typeof sizes
dismissible?: boolean
}>(),
{ size: 'xl', dismissible: true }
)
const emit = defineEmits<{ close: [] }>()
const id = useId()
const hasTitle = ref(false)
const hasDescription = ref(false)
const titleId = `${id}-title`
const descriptionId = `${id}-description`
provide(SlideoverIdsKey, { titleId, descriptionId, hasTitle, hasDescription })
const { depth, cover, isBase, inStack } = useModalStackPosition('slideover')
const locked = useScrollLock()
const rendered = ref(props.open)
const panelElement = ref<HTMLElement>()
let showOthers: (() => void) | undefined
watch(panelElement, (element) => {
showOthers?.()
showOthers = element && !inStack ? hideOthers(element) : undefined
})
function onAfterLeave() {
rendered.value = false
}
function onKeydown(event: KeyboardEvent) {
if (!isEscapeForModal(event) || cover.value > 0 || !props.dismissible) return
event.stopPropagation()
emit('close')
}
function onOutsideMousedown(event: MouseEvent) {
if (event.target === event.currentTarget && cover.value === 0 && props.dismissible) emit('close')
}
watch(
() => props.open,
(open) => {
if (open) rendered.value = true
locked.value = open
},
{ immediate: true }
)
onBeforeUnmount(() => {
showOthers?.()
locked.value = false
})
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition duration-200 ease-out motion-reduce:duration-0"
enter-from-class="opacity-0"
leave-active-class="transition duration-200 ease-in motion-reduce:duration-0"
leave-to-class="opacity-0"
>
<div
v-if="open && isBase"
aria-hidden="true"
class="fixed inset-0 z-50 bg-zinc-950/25 focus:outline-0 dark:bg-zinc-950/50"
/>
</Transition>
<div
v-if="rendered"
class="fixed inset-0 z-50 overflow-hidden"
@mousedown="onOutsideMousedown"
>
<div
:style="{ '--stack-depth': depth, '--stack-cover': cover }"
:class="[
sizes[size],
'fixed top-2 right-2 bottom-[max(calc(var(--spacing)*2),env(safe-area-inset-bottom))] left-2 sm:left-auto sm:w-full',
'origin-left transform-gpu transition-[translate,scale] duration-200 ease-out motion-reduce:transition-none sm:translate-x-[calc(var(--stack-depth)*-1rem)] sm:scale-[calc(1-0.04*var(--stack-depth))]',
'after:pointer-events-none after:absolute after:inset-0 after:rounded-2xl after:bg-zinc-950/30 after:opacity-[calc(var(--stack-cover,0)*0.5)] after:transition-opacity after:duration-200 motion-reduce:after:transition-none dark:after:bg-zinc-950/50',
]"
>
<Transition
appear
enter-active-class="transition duration-200 ease-out will-change-transform motion-reduce:duration-0"
enter-from-class="translate-x-full"
leave-active-class="transition duration-200 ease-in will-change-transform motion-reduce:duration-0"
leave-to-class="translate-x-full"
@after-leave="onAfterLeave"
>
<FocusScope v-if="open" as-child trapped loop @mount-auto-focus="focusAutofocusElement">
<div
ref="panelElement"
role="dialog"
aria-modal="true"
:inert="depth > 0"
:aria-labelledby="hasTitle ? titleId : undefined"
:aria-describedby="hasDescription ? descriptionId : undefined"
@keydown="onKeydown"
:class="[
'flex h-full w-full flex-col overflow-y-auto rounded-2xl p-(--gutter) [--gutter:--spacing(6)] sm:[--gutter:--spacing(8)]',
'bg-white dark:bg-zinc-900',
'shadow-lg ring-1 ring-zinc-950/10 dark:ring-white/10 forced-colors:outline',
]"
>
<slot />
</div>
</FocusScope>
</Transition>
</div>
</div>
</Teleport>
</template>Component API
| Prop | Default | Description |
|---|---|---|
Slideoverbuilt on the Headless UI <Headless.Dialog> component | ||
open | - | Controls whether the slideover is shown. |
onClose | - | Called when the user presses Escape or clicks the backdrop. |
size | xl | Maximum width of the panel, from sm to 3xl. |
dismissible | true | Off, Escape and the backdrop no longer close it: only the panel’s own controls do. |
SlideoverTitlebuilt on the Headless UI <Headless.DialogTitle> component | ||
| This component does not expose any component-specific props. | ||
SlideoverDescriptionbuilt on the <Text> component | ||
| This component does not expose any component-specific props. | ||
SlideoverBodyrenders a <div> element | ||
| This component does not expose any component-specific props. | ||
SlideoverActionsrenders a <div> element | ||
| This component does not expose any component-specific props. | ||
| Prop | Default | Description |
|---|---|---|
Slideoverbuilt on the Reka UI <FocusScope> component | ||
open | - | Controls whether the slideover is shown. |
size | xl | Maximum width of the panel, from sm to 3xl. |
dismissible | true | Off, Escape and the backdrop no longer close it: only the panel’s own controls do. |
@close | - | Emitted when the user presses Escape or clicks the backdrop. |
SlideoverTitlerenders a <h2> element | ||
| This component does not expose any component-specific props. | ||
SlideoverDescriptionbuilt on the <Text> component | ||
| This component does not expose any component-specific props. | ||
SlideoverBodyrenders a <div> element | ||
| This component does not expose any component-specific props. | ||
SlideoverActionsrenders a <div> element | ||
| This component does not expose any component-specific props. | ||
Examples
Basic example
Combine the Slideover, SlideoverTitle, SlideoverDescription, SlideoverBody and SlideoverActions components to build a slideover panel:
'use client'
import { useState } from 'react'
import { Button } from '@/components/button'
import {
Slideover,
SlideoverActions,
SlideoverBody,
SlideoverDescription,
SlideoverTitle,
} from '@/components/slideover'
export default function Example() {
let [isOpen, setIsOpen] = useState(false)
return (
<>
<Button type="button" onClick={() => setIsOpen(true)}>
View order
</Button>
<Slideover open={isOpen} onClose={setIsOpen}>
<SlideoverTitle>Order #1011</SlideoverTitle>
<SlideoverDescription>
Placed on May 9, 2026 and paid with Mobile Money.
</SlideoverDescription>
<SlideoverBody>{/* ... */}</SlideoverBody>
<SlideoverActions>
<Button plain onClick={() => setIsOpen(false)}>
Close
</Button>
<Button onClick={() => setIsOpen(false)}>Refund order</Button>
</SlideoverActions>
</Slideover>
</>
)
}<script setup lang="ts">
import { ref } from 'vue'
import Button from '@/components/button.vue'
import Slideover from '@/components/slideover.vue'
import SlideoverActions from '@/components/slideover-actions.vue'
import SlideoverBody from '@/components/slideover-body.vue'
import SlideoverDescription from '@/components/slideover-description.vue'
import SlideoverTitle from '@/components/slideover-title.vue'
const isOpen = ref(false)
</script>
<template>
<Button type="button" @click="isOpen = true">View order</Button>
<Slideover :open="isOpen" @close="isOpen = false">
<SlideoverTitle>Order #1011</SlideoverTitle>
<SlideoverDescription>
Placed on May 9, 2026 and paid with Mobile Money.
</SlideoverDescription>
<SlideoverBody><!-- ... --></SlideoverBody>
<SlideoverActions>
<Button plain @click="isOpen = false">Close</Button>
<Button @click="isOpen = false">Refund order</Button>
</SlideoverActions>
</Slideover>
</template>Slideovers are controlled components: open and close them with the open prop and the onClose handler. The panel slides in from the right edge of the screen.
Slideover width
Use the size prop on the Slideover component to control the max-width of the panel:
<Slideover size="md" open={isOpen} onClose={setIsOpen}>
<SlideoverTitle>Order #1011</SlideoverTitle>
<SlideoverDescription>
Placed on May 9, 2026 and paid with Mobile Money.
</SlideoverDescription>
<SlideoverBody>{/* ... */}</SlideoverBody>
</Slideover><Slideover size="md" :open="isOpen" @close="isOpen = false">
<SlideoverTitle>Order #1011</SlideoverTitle>
<SlideoverDescription>
Placed on May 9, 2026 and paid with Mobile Money.
</SlideoverDescription>
<SlideoverBody><!-- ... --></SlideoverBody>
</Slideover>Available size options are sm, md, lg, xl, 2xl and 3xl.
With close button
Slideovers often stay open while the user works elsewhere on the page, so give them an explicit close button in the header:
<Slideover open={isOpen} onClose={setIsOpen}>
<div className="flex items-start justify-between gap-4">
<SlideoverTitle>pay.arthurmonney.me</SlideoverTitle>
<button
type="button"
className="-m-1 rounded-lg p-1 text-zinc-500 hover:text-zinc-950 dark:text-zinc-400 dark:hover:text-white"
onClick={() => setIsOpen(false)}
>
<XMarkIcon className="size-4 shrink-0" />
<span className="sr-only">Close</span>
</button>
</div>
<SlideoverDescription>
DNS configuration can vary across providers. Refer to the{' '}
<TextLink href="#">provider documentation</TextLink> for more information.
</SlideoverDescription>
<SlideoverBody>{/* ... */}</SlideoverBody>
</Slideover><Slideover :open="isOpen" @close="isOpen = false">
<div class="flex items-start justify-between gap-4">
<SlideoverTitle>pay.arthurmonney.me</SlideoverTitle>
<button
type="button"
class="-m-1 rounded-lg p-1 text-zinc-500 hover:text-zinc-950 dark:text-zinc-400 dark:hover:text-white"
@click="isOpen = false"
>
<XMarkIcon class="size-4 shrink-0" />
<span class="sr-only">Close</span>
</button>
</div>
<SlideoverDescription>
DNS configuration can vary across providers. Refer to the
<TextLink href="#">provider documentation</TextLink> for more information.
</SlideoverDescription>
<SlideoverBody><!-- ... --></SlideoverBody>
</Slideover>With scrolling content
Slideovers become scrollable on their own when their content is taller than the viewport:
'use client'
import { useState } from 'react'
import { Button } from '@/components/button'
import {
Slideover,
SlideoverActions,
SlideoverBody,
SlideoverDescription,
SlideoverTitle,
} from '@/components/slideover'
export default function Example() {
let [isOpen, setIsOpen] = useState(false)
return (
<>
<Button type="button" onClick={() => setIsOpen(true)}>
View order
</Button>
<Slideover open={isOpen} onClose={setIsOpen}>
<SlideoverTitle>Order #1011</SlideoverTitle>
<SlideoverDescription>
Placed on May 9, 2026 and paid with Mobile Money.
</SlideoverDescription>
<SlideoverBody>{/* ... */}</SlideoverBody>
<SlideoverActions>
<Button plain onClick={() => setIsOpen(false)}>
Close
</Button>
<Button onClick={() => setIsOpen(false)}>Refund order</Button>
</SlideoverActions>
</Slideover>
</>
)
}<script setup lang="ts">
import { ref } from 'vue'
import Button from '@/components/button.vue'
import Slideover from '@/components/slideover.vue'
import SlideoverActions from '@/components/slideover-actions.vue'
import SlideoverBody from '@/components/slideover-body.vue'
import SlideoverDescription from '@/components/slideover-description.vue'
import SlideoverTitle from '@/components/slideover-title.vue'
const isOpen = ref(false)
</script>
<template>
<Button type="button" @click="isOpen = true">View order</Button>
<Slideover :open="isOpen" @close="isOpen = false">
<SlideoverTitle>Order #1011</SlideoverTitle>
<SlideoverDescription>
Placed on May 9, 2026 and paid with Mobile Money.
</SlideoverDescription>
<SlideoverBody><!-- ... --></SlideoverBody>
<SlideoverActions>
<Button plain @click="isOpen = false">Close</Button>
<Button @click="isOpen = false">Refund order</Button>
</SlideoverActions>
</Slideover>
</template>Stacked slideovers
A slideover can open another slideover and wait for its answer: wrap the app in ModalStackProvider, then open panels from anywhere with useModalStack():
'use client'
import { useState } from 'react'
import { Button } from '@/components/button'
import { useModalStack, type ModalRenderProps } from '@/components/modal-stack'
import { Slideover, SlideoverActions, SlideoverBody, SlideoverTitle } from '@/components/slideover'
function AddressSlideover({ isOpen, close }: ModalRenderProps<Address>) {
return (
<Slideover size="md" open={isOpen} onClose={() => close()}>
<SlideoverTitle>Shipping address</SlideoverTitle>
<SlideoverBody>
{addresses.map((address) => (
<button key={address.id} type="button" onClick={() => close(address)}>
{address.label}
</button>
))}
</SlideoverBody>
<SlideoverActions>
<Button plain onClick={() => close()}>
Cancel
</Button>
</SlideoverActions>
</Slideover>
)
}
function CustomerSlideover({ isOpen, close }: ModalRenderProps<Address>) {
let { open } = useModalStack()
let [address, setAddress] = useState(addresses[0])
async function changeAddress() {
let selected = await open<Address>((modal) => <AddressSlideover {...modal} />)
if (selected) setAddress(selected)
}
return (
<Slideover size="md" open={isOpen} onClose={() => close()}>
<SlideoverTitle>Amara Okonkwo</SlideoverTitle>
<SlideoverBody>
<Button outline onClick={changeAddress}>
Change address
</Button>
</SlideoverBody>
<SlideoverActions>
<Button plain onClick={() => close()}>
Cancel
</Button>
<Button onClick={() => close(address)}>Ship here</Button>
</SlideoverActions>
</Slideover>
)
}
export default function Example() {
let { open } = useModalStack()
return (
<Button type="button" onClick={() => open((modal) => <OrderSlideover {...modal} />)}>
View order
</Button>
)
}<script setup lang="ts">
import { ref } from 'vue'
import Button from '@/components/button.vue'
import { useModalStack } from '@/components/modal-stack'
import Slideover from '@/components/slideover.vue'
import SlideoverActions from '@/components/slideover-actions.vue'
import SlideoverBody from '@/components/slideover-body.vue'
import SlideoverTitle from '@/components/slideover-title.vue'
import AddressSlideover from './AddressSlideover.vue'
defineProps<{ isOpen: boolean }>()
const emit = defineEmits<{ close: [value?: Address] }>()
const { open } = useModalStack()
const address = ref<Address>(addresses[0])
async function changeAddress() {
const selected = await open<Address>(AddressSlideover)
if (selected) address.value = selected
}
</script>
<template>
<Slideover size="md" :open="isOpen" @close="emit('close')">
<SlideoverTitle>Amara Okonkwo</SlideoverTitle>
<SlideoverBody>
<Button outline @click="changeAddress">Change address</Button>
</SlideoverBody>
<SlideoverActions>
<Button plain @click="emit('close')">Cancel</Button>
<Button @click="emit('close', address)">Ship here</Button>
</SlideoverActions>
</Slideover>
</template>Each covered panel slides left and scales down instead of unmounting, so it keeps its scroll position and its form state while the panel on top has the focus. Three levels is the practical limit, and maxDepth warns past that.
Slideovers stack with slideovers, dialogs with dialogs. A dialog opened from a slideover simply sits on top of it without pushing it aside, since it is a plane of its own. See stacked dialogs for the full API.