Skip to content
  1. Overlays
  2. Slideover

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'
      )}
    />
  )
}

Also copy Dialog, Text.

<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>

Also copy Dialog, Text.

Component API

PropDefaultDescription
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.
sizexlMaximum width of the panel, from sm to 3xl.
dismissibletrueOff, 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.
PropDefaultDescription
Slideoverbuilt on the Reka UI <FocusScope> component
open-Controls whether the slideover is shown.
sizexlMaximum width of the panel, from sm to 3xl.
dismissibletrueOff, 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.