Skip to content

Slideover

A dialog that slides in from any side of the screen.

Demo

This requires the following components to be installed:

This requires the following composables to be installed:

This requires the following types to be installed:

This requires the following theme to be installed:

Component

Slideover.vue
vue
<script lang="ts">
import type { ButtonProps } from '@/ui/components/Button.vue'
import type { ComponentConfig } from '@/ui/utils/utils'
import type { DialogContentEmits, DialogContentProps, DialogRootEmits, DialogRootProps } from 'reka-ui'
import type { EmitsToProps } from 'vue'
import Button from '@/ui/components/Button.vue'
import { usePortal } from '@/ui/composables/usePortal'
import theme from '@/ui/theme/slideover'
import { reactivePick } from '@vueuse/shared'
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, DialogTrigger, useForwardPropsEmits, VisuallyHidden } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, toRef } from 'vue'

type Slideover = ComponentConfig<typeof theme>

export interface SlideoverProps extends DialogRootProps {
  title?: string
  description?: string
  content?: Omit<DialogContentProps, 'as' | 'asChild' | 'forceMount'> & Partial<EmitsToProps<DialogContentEmits>>
  overlay?: boolean
  transition?: boolean
  side?: Slideover['variants']['side']
  portal?: boolean | string | HTMLElement
  close?: boolean | Partial<ButtonProps>
  closeIcon?: string
  dismissible?: boolean
  class?: any
  ui?: Slideover['slots']
}

export interface SlideoverEmits extends DialogRootEmits {
  'after:leave': []
}

export interface SlideoverSlots {
  default: (props: { open: boolean }) => any
  content: (props?: object) => any
  header: (props?: object) => any
  title: (props?: object) => any
  description: (props?: object) => any
  close: (props: { ui: { [K in keyof Required<Slideover['slots']>]: (props?: Record<string, any>) => string } }) => any
  body: (props?: object) => any
  footer: (props?: object) => any
}
</script>

<script setup lang="ts">
const props = withDefaults(defineProps<SlideoverProps>(), {
  close: true,
  portal: true,
  overlay: true,
  transition: true,
  modal: true,
  dismissible: true,
  side: 'right',
})
const emits = defineEmits<SlideoverEmits>()
const slots = defineSlots<SlideoverSlots>()

const rootProps = useForwardPropsEmits(reactivePick(props, 'open', 'defaultOpen', 'modal'), emits)
const portalProps = usePortal(toRef(() => props.portal))
const contentProps = toRef(() => props.content)
const contentEvents = computed(() => {
  const events = {
    closeAutoFocus: (e: Event) => e.preventDefault(),
  }

  if (!props.dismissible) {
    return {
      pointerDownOutside: (e: Event) => e.preventDefault(),
      interactOutside: (e: Event) => e.preventDefault(),
      escapeKeyDown: (e: Event) => e.preventDefault(),
      ...events,
    }
  }

  return events
})

const ui = computed(() => tv(theme)({
  transition: props.transition,
  side: props.side,
}))
</script>

<!-- eslint-disable vue/custom-event-name-casing -->
<template>
  <DialogRoot v-slot="{ open }" v-bind="rootProps">
    <DialogTrigger v-if="!!slots.default" as-child :class="props.class">
      <slot :open="open" />
    </DialogTrigger>

    <DialogPortal v-bind="portalProps">
      <DialogOverlay v-if="overlay" :class="ui.overlay({ class: props.ui?.overlay })" />

      <DialogContent :data-side="side" :class="ui.content({ class: [!slots.default && props.class, props.ui?.content] })" v-bind="contentProps" @after-leave="emits('after:leave')" v-on="contentEvents">
        <VisuallyHidden v-if="!!slots.content && ((title || !!slots.title) || (description || !!slots.description))">
          <DialogTitle v-if="title || !!slots.title">
            <slot name="title">
              {{ title }}
            </slot>
          </DialogTitle>

          <DialogDescription v-if="description || !!slots.description">
            <slot name="description">
              {{ description }}
            </slot>
          </DialogDescription>
        </VisuallyHidden>

        <slot name="content">
          <div v-if="!!slots.header || (title || !!slots.title) || (description || !!slots.description) || (close || !!slots.close)" :class="ui.header({ class: props.ui?.header })">
            <slot name="header">
              <div :class="ui.wrapper({ class: props.ui?.wrapper })">
                <DialogTitle v-if="title || !!slots.title" :class="ui.title({ class: props.ui?.title })">
                  <slot name="title">
                    {{ title }}
                  </slot>
                </DialogTitle>

                <DialogDescription v-if="description || !!slots.description" :class="ui.description({ class: props.ui?.description })">
                  <slot name="description">
                    {{ description }}
                  </slot>
                </DialogDescription>
              </div>

              <DialogClose v-if="close || !!slots.close" as-child>
                <slot name="close" :ui="ui">
                  <Button
                    v-if="close"
                    :icon="closeIcon"
                    size="md"
                    color="neutral"
                    variant="ghost"
                    aria-label="close"
                    v-bind="(typeof close === 'object' ? close as Partial<ButtonProps> : {})"
                    :class="ui.close({ class: props.ui?.close })"
                  />
                </slot>
              </DialogClose>
            </slot>
          </div>

          <div :class="ui.body({ class: props.ui?.body })">
            <slot name="body" />
          </div>

          <div v-if="!!slots.footer" :class="ui.footer({ class: props.ui?.footer })">
            <slot name="footer" />
          </div>
        </slot>
      </DialogContent>
    </DialogPortal>
  </DialogRoot>
</template>
Slideover.vue
vue
<script lang="ts">
import type { ButtonProps } from '@/UI/Components/Button.vue'
import type { ComponentConfig } from '@/UI/Utils/utils'
import type { DialogContentEmits, DialogContentProps, DialogRootEmits, DialogRootProps } from 'reka-ui'
import type { EmitsToProps } from 'vue'
import Button from '@/UI/Components/Button.vue'
import { usePortal } from '@/UI/Composables/usePortal'
import theme from '@/UI/Theme/slideover'
import { reactivePick } from '@vueuse/shared'
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, DialogTrigger, useForwardPropsEmits, VisuallyHidden } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, toRef } from 'vue'

type Slideover = ComponentConfig<typeof theme>

export interface SlideoverProps extends DialogRootProps {
  title?: string
  description?: string
  content?: Omit<DialogContentProps, 'as' | 'asChild' | 'forceMount'> & Partial<EmitsToProps<DialogContentEmits>>
  overlay?: boolean
  transition?: boolean
  side?: Slideover['variants']['side']
  portal?: boolean | string | HTMLElement
  close?: boolean | Partial<ButtonProps>
  closeIcon?: string
  dismissible?: boolean
  class?: any
  ui?: Slideover['slots']
}

export interface SlideoverEmits extends DialogRootEmits {
  'after:leave': []
}

export interface SlideoverSlots {
  default: (props: { open: boolean }) => any
  content: (props?: object) => any
  header: (props?: object) => any
  title: (props?: object) => any
  description: (props?: object) => any
  close: (props: { ui: { [K in keyof Required<Slideover['slots']>]: (props?: Record<string, any>) => string } }) => any
  body: (props?: object) => any
  footer: (props?: object) => any
}
</script>

<script setup lang="ts">
const props = withDefaults(defineProps<SlideoverProps>(), {
  close: true,
  portal: true,
  overlay: true,
  transition: true,
  modal: true,
  dismissible: true,
  side: 'right',
})
const emits = defineEmits<SlideoverEmits>()
const slots = defineSlots<SlideoverSlots>()

const rootProps = useForwardPropsEmits(reactivePick(props, 'open', 'defaultOpen', 'modal'), emits)
const portalProps = usePortal(toRef(() => props.portal))
const contentProps = toRef(() => props.content)
const contentEvents = computed(() => {
  const events = {
    closeAutoFocus: (e: Event) => e.preventDefault(),
  }

  if (!props.dismissible) {
    return {
      pointerDownOutside: (e: Event) => e.preventDefault(),
      interactOutside: (e: Event) => e.preventDefault(),
      escapeKeyDown: (e: Event) => e.preventDefault(),
      ...events,
    }
  }

  return events
})

const ui = computed(() => tv(theme)({
  transition: props.transition,
  side: props.side,
}))
</script>

<!-- eslint-disable vue/custom-event-name-casing -->
<template>
  <DialogRoot v-slot="{ open }" v-bind="rootProps">
    <DialogTrigger v-if="!!slots.default" as-child :class="props.class">
      <slot :open="open" />
    </DialogTrigger>

    <DialogPortal v-bind="portalProps">
      <DialogOverlay v-if="overlay" :class="ui.overlay({ class: props.ui?.overlay })" />

      <DialogContent :data-side="side" :class="ui.content({ class: [!slots.default && props.class, props.ui?.content] })" v-bind="contentProps" @after-leave="emits('after:leave')" v-on="contentEvents">
        <VisuallyHidden v-if="!!slots.content && ((title || !!slots.title) || (description || !!slots.description))">
          <DialogTitle v-if="title || !!slots.title">
            <slot name="title">
              {{ title }}
            </slot>
          </DialogTitle>

          <DialogDescription v-if="description || !!slots.description">
            <slot name="description">
              {{ description }}
            </slot>
          </DialogDescription>
        </VisuallyHidden>

        <slot name="content">
          <div v-if="!!slots.header || (title || !!slots.title) || (description || !!slots.description) || (close || !!slots.close)" :class="ui.header({ class: props.ui?.header })">
            <slot name="header">
              <div :class="ui.wrapper({ class: props.ui?.wrapper })">
                <DialogTitle v-if="title || !!slots.title" :class="ui.title({ class: props.ui?.title })">
                  <slot name="title">
                    {{ title }}
                  </slot>
                </DialogTitle>

                <DialogDescription v-if="description || !!slots.description" :class="ui.description({ class: props.ui?.description })">
                  <slot name="description">
                    {{ description }}
                  </slot>
                </DialogDescription>
              </div>

              <DialogClose v-if="close || !!slots.close" as-child>
                <slot name="close" :ui="ui">
                  <Button
                    v-if="close"
                    :icon="closeIcon"
                    size="md"
                    color="neutral"
                    variant="ghost"
                    aria-label="close"
                    v-bind="(typeof close === 'object' ? close as Partial<ButtonProps> : {})"
                    :class="ui.close({ class: props.ui?.close })"
                  />
                </slot>
              </DialogClose>
            </slot>
          </div>

          <div :class="ui.body({ class: props.ui?.body })">
            <slot name="body" />
          </div>

          <div v-if="!!slots.footer" :class="ui.footer({ class: props.ui?.footer })">
            <slot name="footer" />
          </div>
        </slot>
      </DialogContent>
    </DialogPortal>
  </DialogRoot>
</template>

Theme

slideover.ts
ts
export default {
  slots: {
    overlay: '',
    content: '',
    header: '',
    wrapper: '',
    body: '',
    footer: '',
    title: '',
    description: '',
    close: '',
  },
  variants: {
    side: {
      top: {
        content: '',
      },
      right: {
        content: '',
      },
      bottom: {
        content: '',
      },
      left: {
        content: '',
      },
    },
    transition: {
      true: {
        overlay: '',
      },
    },
  },
  compoundVariants: [],
}
View Nuxt UI theme
slideover.ts
ts
export default {
  slots: {
    overlay: 'fixed inset-0 bg-elevated/75',
    content: 'fixed bg-default divide-y divide-default sm:ring ring-default sm:shadow-lg flex flex-col focus:outline-none',
    header: 'flex items-center gap-1.5 p-4 sm:px-6 min-h-16',
    wrapper: '',
    body: 'flex-1 overflow-y-auto p-4 sm:p-6',
    footer: 'flex items-center gap-1.5 p-4 sm:px-6',
    title: 'text-highlighted font-semibold',
    description: 'mt-1 text-muted text-sm',
    close: 'absolute top-4 end-4',
  },
  variants: {
    side: {
      top: {
        content: 'inset-x-0 top-0 max-h-full',
      },
      right: {
        content: 'right-0 inset-y-0 w-full max-w-md',
      },
      bottom: {
        content: 'inset-x-0 bottom-0 max-h-full',
      },
      left: {
        content: 'left-0 inset-y-0 w-full max-w-md',
      },
    },
    transition: {
      true: {
        overlay: 'data-[state=open]:animate-[fade-in_200ms_ease-out] data-[state=closed]:animate-[fade-out_200ms_ease-in]',
      },
    },
  },
  compoundVariants: [
    {
      transition: true,
      side: 'top',
      class: {
        content: 'data-[state=open]:animate-[slide-in-from-top_200ms_ease-in-out] data-[state=closed]:animate-[slide-out-to-top_200ms_ease-in-out]',
      },
    } as const,
    {
      transition: true,
      side: 'right',
      class: {
        content: 'data-[state=open]:animate-[slide-in-from-right_200ms_ease-in-out] data-[state=closed]:animate-[slide-out-to-right_200ms_ease-in-out]',
      },
    } as const,
    {
      transition: true,
      side: 'bottom',
      class: {
        content: 'data-[state=open]:animate-[slide-in-from-bottom_200ms_ease-in-out] data-[state=closed]:animate-[slide-out-to-bottom_200ms_ease-in-out]',
      },
    } as const,
    {
      transition: true,
      side: 'left',
      class: {
        content: 'data-[state=open]:animate-[slide-in-from-left_200ms_ease-in-out] data-[state=closed]:animate-[slide-out-to-left_200ms_ease-in-out]',
      },
    } as const,
  ],
}

Test

To test this component, you can use the following test file:

Slideover.test.ts
ts
import type { RenderOptions } from '@testing-library/vue'
import Slideover from '@/ui/components/Slideover.vue'
import { render } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'

describe('slideover', () => {
  const props = { open: true, portal: false }

  it.each<[string, RenderOptions<typeof Slideover>]>([
    // Props
    ['with open', { props }],
    ['with title', { props: { ...props, title: 'Title' } }],
    ['with description', { props: { ...props, title: 'Title', description: 'Description' } }],
    ['with left side', { props: { ...props, side: 'left' as const, title: 'Title', description: 'Description' } }],
    ['with top side', { props: { ...props, side: 'top' as const, title: 'Title', description: 'Description' } }],
    ['with bottom side', { props: { ...props, side: 'bottom' as const, title: 'Title', description: 'Description' } }],
    ['without overlay', { props: { ...props, overlay: false, title: 'Title', description: 'Description' } }],
    ['without transition', { props: { ...props, transition: false, title: 'Title', description: 'Description' } }],
    ['without close', { props: { ...props, close: false, title: 'Title', description: 'Description' } }],
    ['with class', { props: { ...props, class: 'bg-(--ui-bg-elevated)' } }],
    ['with ui', { props: { ...props, ui: { close: 'end-2' } } }],
    // Slots
    ['with default slot', { props, slots: { default: () => 'Default slot' } }],
    ['with content slot', { props, slots: { content: () => 'Content slot' } }],
    ['with header slot', { props, slots: { header: () => 'Header slot' } }],
    ['with title slot', { props, slots: { title: () => 'Title slot' } }],
    ['with description slot', { props, slots: { description: () => 'Description slot' } }],
    ['with close slot', { props, slots: { close: () => 'Close slot' } }],
    ['with body slot', { props, slots: { body: () => 'Body slot' } }],
    ['with footer slot', { props, slots: { footer: () => 'Footer slot' } }],
  ] as const)('renders %s correctly', async (name, options) => {
    const { html } = render(Slideover, options)

    await new Promise(resolve => setTimeout(resolve, 0))

    expect(html()).matchSnapshot()
  })
})
Slideover.test.ts
ts
import type { RenderOptions } from '@testing-library/vue'
import Slideover from '@/UI/Components/Slideover.vue'
import { render } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'

describe('slideover', () => {
  const props = { open: true, portal: false }

  it.each<[string, RenderOptions<typeof Slideover>]>([
    // Props
    ['with open', { props }],
    ['with title', { props: { ...props, title: 'Title' } }],
    ['with description', { props: { ...props, title: 'Title', description: 'Description' } }],
    ['with left side', { props: { ...props, side: 'left' as const, title: 'Title', description: 'Description' } }],
    ['with top side', { props: { ...props, side: 'top' as const, title: 'Title', description: 'Description' } }],
    ['with bottom side', { props: { ...props, side: 'bottom' as const, title: 'Title', description: 'Description' } }],
    ['without overlay', { props: { ...props, overlay: false, title: 'Title', description: 'Description' } }],
    ['without transition', { props: { ...props, transition: false, title: 'Title', description: 'Description' } }],
    ['without close', { props: { ...props, close: false, title: 'Title', description: 'Description' } }],
    ['with class', { props: { ...props, class: 'bg-(--ui-bg-elevated)' } }],
    ['with ui', { props: { ...props, ui: { close: 'end-2' } } }],
    // Slots
    ['with default slot', { props, slots: { default: () => 'Default slot' } }],
    ['with content slot', { props, slots: { content: () => 'Content slot' } }],
    ['with header slot', { props, slots: { header: () => 'Header slot' } }],
    ['with title slot', { props, slots: { title: () => 'Title slot' } }],
    ['with description slot', { props, slots: { description: () => 'Description slot' } }],
    ['with close slot', { props, slots: { close: () => 'Close slot' } }],
    ['with body slot', { props, slots: { body: () => 'Body slot' } }],
    ['with footer slot', { props, slots: { footer: () => 'Footer slot' } }],
  ] as const)('renders %s correctly', async (name, options) => {
    const { html } = render(Slideover, options)

    await new Promise(resolve => setTimeout(resolve, 0))

    expect(html()).matchSnapshot()
  })
})

Contributors

barbapapazes

Changelog

6aa4d - fix: use close icon in slideover (#111) on 1/9/2025
c615c - feat: add custom eslint rule to disallow relative imports (#81) on 1/7/2025
7d577 - feat: add toast component (#77) on 1/3/2025
51f9b - feat: add slideover component (#47) on 12/18/2024