Slideover

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

Usage

Use a Button or any other component in the default slot of the Slideover.

Then, use the #content slot to add the content displayed when the Slideover is open.

<template>
  <PSlideover>
    <PButton label="Open" color="neutral" variant="subtle" />

    <template #content>
      <Placeholder class="h-full m-4" />
    </template>
  </PSlideover>
</template>

You can also use the #header, #body and #footer slots to customize the Slideover's content.

Title

Use the title prop to set the title of the Slideover's header.

<template>
  <PSlideover title="Slideover with title">
    <PButton label="Open" color="neutral" variant="subtle" />

    <template #body>
      <Placeholder class="h-full" />
    </template>
  </PSlideover>
</template>

Description

Use the description prop to set the description of the Slideover's header.

<template>
  <PSlideover
    title="Slideover with description"
    description="Lorem ipsum dolor sit amet, consectetur adipiscing elit."
  >
    <PButton label="Open" color="neutral" variant="subtle" />

    <template #body>
      <Placeholder class="h-full" />
    </template>
  </PSlideover>
</template>

Close

Use the close prop to customize or hide the close button (with false value) displayed in the Slideover's header.

You can pass any property from the Button component to customize it.

<template>
  <PSlideover
    title="Slideover with close button"
    :close="{
      color: 'primary',
      variant: 'outline',
      class: 'rounded-full'
    }"
  >
    <PButton label="Open" color="neutral" variant="subtle" />

    <template #body>
      <Placeholder class="h-full" />
    </template>
  </PSlideover>
</template>
The close button is not displayed if the #content slot is used as it's a part of the header.

Close Icon

Use the close-icon prop to customize the close button Icon. Defaults to i-lucide-x.

<template>
  <PSlideover title="Slideover with close button" close-icon="i-lucide-arrow-right">
    <PButton label="Open" color="neutral" variant="subtle" />

    <template #body>
      <Placeholder class="h-full" />
    </template>
  </PSlideover>
</template>
You can customize this icon globally in your app.config.ts under ui.icons.close key.
You can customize this icon globally in your vite.config.ts under ui.icons.close key.

Side

Use the side prop to set the side of the screen where the Slideover will slide in from. Defaults to right.

<template>
  <PSlideover side="left" title="Slideover with side">
    <PButton label="Open" color="neutral" variant="subtle" />

    <template #body>
      <Placeholder class="h-full min-h-48" />
    </template>
  </PSlideover>
</template>

Inset

Use the inset prop to inset the Slideover from the edges.

<template>
  <PSlideover side="right" inset title="Slideover with inset">
    <PButton label="Open" color="neutral" variant="subtle" />

    <template #body>
      <Placeholder class="min-w-96 min-h-96 size-full" />
    </template>
  </PSlideover>
</template>

Transition

Use the transition prop to control whether the Slideover is animated or not. Defaults to true.

<template>
  <PSlideover :transition="false" title="Slideover without transition">
    <PButton label="Open" color="neutral" variant="subtle" />

    <template #body>
      <Placeholder class="h-full" />
    </template>
  </PSlideover>
</template>

Overlay

Use the overlay prop to control whether the Slideover has an overlay or not. Defaults to true.

<template>
  <PSlideover :overlay="false" title="Slideover without overlay">
    <PButton label="Open" color="neutral" variant="subtle" />

    <template #body>
      <Placeholder class="h-full" />
    </template>
  </PSlideover>
</template>

Use the modal prop to control whether the Slideover blocks interaction with outside content. Defaults to true.

When modal is set to false, the overlay is automatically disabled and outside content becomes interactive.
<template>
  <PSlideover title="Slideover interactive">
    <PButton label="Open" color="neutral" variant="subtle" />

    <template #body>
      <Placeholder class="h-full" />
    </template>
  </PSlideover>
</template>

Dismissible

Use the dismissible prop to control whether the Slideover is dismissible when clicking outside of it or pressing escape. Defaults to true.

A close:prevent event will be emitted when the user tries to close it.
You can combine modal: false with dismissible: false to make the Slideover's background interactive without closing it.
<template>
  <PSlideover :dismissible="false" modal title="Slideover non-dismissible">
    <PButton label="Open" color="neutral" variant="subtle" />

    <template #body>
      <Placeholder class="h-full" />
    </template>
  </PSlideover>
</template>

Unmount

Use the unmount-on-hide prop to prevent the Slideover's content from being unmounted when it is closed. Defaults to true.

<template>
  <PSlideover :unmount-on-hide="false" title="Slideover">
    <PButton label="Open" color="neutral" variant="subtle" />

    <template #body>
      <Placeholder class="h-full" />
    </template>
  </PSlideover>
</template>
You can inspect the DOM to see the Slideover's content being rendered even while it is closed.
When the portal prop is set to false, the content is also rendered on the server. This is useful to render an open Slideover during SSR without a flash on page load, or to expose its content for SEO.

Examples

Control open state

You can control the open state by using the default-open prop or the v-model:open directive.

<script setup lang="ts">
const open = ref(false);

defineShortcuts({
  o: () => open.value = !open.value,
});
</script>

<template>
  <PSlideover v-model:open="open">
    <PButton
      label="Open"
      color="neutral"
      variant="subtle"
    />

    <template #content>
      <Placeholder class="m-4 h-full" />
    </template>
  </PSlideover>
</template>
<script setup lang="ts">
import { ref } from 'vue'

const open = ref(false);

defineShortcuts({
  o: () => open.value = !open.value,
});
</script>

<template>
  <PSlideover v-model:open="open">
    <PButton
      label="Open"
      color="neutral"
      variant="subtle"
    />

    <template #content>
      <Placeholder class="m-4 h-full" />
    </template>
  </PSlideover>
</template>
In this example, leveraging defineShortcuts, you can toggle the Slideover by pressing O.
This lets you move the trigger outside of the Slideover or remove it entirely.

Programmatic usage

You can use the useOverlay composable to open a Slideover programmatically.

Make sure to wrap your app with the App component which uses the OverlayProvider component.

First, create a slideover component that will be opened programmatically:

SlideoverExample.vue
<script setup lang="ts">
defineProps<{
  count: number
}>()

const emit = defineEmits<{ close: [boolean] }>()
</script>

<template>
  <PSlideover
    :close="{ onClick: () => emit('close', false) }"
    :description="`This slideover was opened programmatically ${count} times`"
  >
    <template #body>
      <Placeholder class="h-full" />
    </template>

    <template #footer>
      <div class="flex gap-2">
        <PButton color="neutral" label="Dismiss" @click="emit('close', false)" />
        <PButton label="Success" @click="emit('close', true)" />
      </div>
    </template>
  </PSlideover>
</template>
We are emitting a close event when the slideover is closed or dismissed here. You can emit any data through the close event, and that data becomes the resolved value of open(). The event must be emitted for the promise to resolve.

Then, use it in your app:

<script setup lang="ts">
import { LazySlideoverExample } from '#components';

const count = ref(0);

const toast = useToast();
const overlay = useOverlay();

const slideover = overlay.create(LazySlideoverExample);

async function open() {
  const instance = slideover.open({
    count: count.value,
  });

  const shouldIncrement = await instance.result;

  if (shouldIncrement) {
    count.value++;

    toast.add({
      title: `Success: ${shouldIncrement}`,
      color: 'success',
      id: 'slideover-success',
    });

    // Update the count
    slideover.patch({
      count: count.value,
    });
    return;
  }

  toast.add({
    title: `Dismissed: ${shouldIncrement}`,
    color: 'error',
    id: 'slideover-dismiss',
  });
}
</script>

<template>
  <PButton
    label="Open"
    color="neutral"
    variant="subtle"
    @click="open"
  />
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { LazySlideoverExample } from '#components';

const count = ref(0);

const toast = useToast();
const overlay = useOverlay();

const slideover = overlay.create(LazySlideoverExample);

async function open() {
  const instance = slideover.open({
    count: count.value,
  });

  const shouldIncrement = await instance.result;

  if (shouldIncrement) {
    count.value++;

    toast.add({
      title: `Success: ${shouldIncrement}`,
      color: 'success',
      id: 'slideover-success',
    });

    // Update the count
    slideover.patch({
      count: count.value,
    });
    return;
  }

  toast.add({
    title: `Dismissed: ${shouldIncrement}`,
    color: 'error',
    id: 'slideover-dismiss',
  });
}
</script>

<template>
  <PButton
    label="Open"
    color="neutral"
    variant="subtle"
    @click="open"
  />
</template>
You can close the slideover within the slideover component by emitting emit('close').

Nested slideovers

You can nest slideovers within each other.

<script setup lang="ts">
const first = ref(false);
const second = ref(false);
</script>

<template>
  <PSlideover
    v-model:open="first"
    title="First slideover"
    :ui="{ footer: 'justify-end' }"
  >
    <PButton
      color="neutral"
      variant="subtle"
      label="Open"
    />

    <template #body>
      <Placeholder class="h-full" />
    </template>

    <template #footer>
      <PButton
        label="Close"
        color="neutral"
        variant="outline"
        @click="first = false"
      />

      <PSlideover
        v-model:open="second"
        title="Second slideover"
        :ui="{ footer: 'justify-end' }"
      >
        <PButton
          label="Open second"
          color="neutral"
        />

        <template #body>
          <Placeholder class="h-full" />
        </template>

        <template #footer>
          <PButton
            label="Close"
            color="neutral"
            variant="outline"
            @click="second = false"
          />
        </template>
      </PSlideover>
    </template>
  </PSlideover>
</template>
<script setup lang="ts">
import { ref } from 'vue'

const first = ref(false);
const second = ref(false);
</script>

<template>
  <PSlideover
    v-model:open="first"
    title="First slideover"
    :ui="{ footer: 'justify-end' }"
  >
    <PButton
      color="neutral"
      variant="subtle"
      label="Open"
    />

    <template #body>
      <Placeholder class="h-full" />
    </template>

    <template #footer>
      <PButton
        label="Close"
        color="neutral"
        variant="outline"
        @click="first = false"
      />

      <PSlideover
        v-model:open="second"
        title="Second slideover"
        :ui="{ footer: 'justify-end' }"
      >
        <PButton
          label="Open second"
          color="neutral"
        />

        <template #body>
          <Placeholder class="h-full" />
        </template>

        <template #footer>
          <PButton
            label="Close"
            color="neutral"
            variant="outline"
            @click="second = false"
          />
        </template>
      </PSlideover>
    </template>
  </PSlideover>
</template>

Use the #footer slot to add content after the Slideover's body.

<script setup lang="ts">
const open = ref(false);
</script>

<template>
  <PSlideover
    v-model:open="open"
    title="Slideover with footer"
    description="This is useful when you want a form in a Slideover."
    :ui="{ footer: 'justify-end' }"
  >
    <PButton
      label="Open"
      color="neutral"
      variant="subtle"
    />

    <template #body>
      <Placeholder class="h-full" />
    </template>

    <template #footer="{ close }">
      <PButton
        label="Cancel"
        color="neutral"
        variant="outline"
        @click="close"
      />
      <PButton
        label="Submit"
        color="neutral"
      />
    </template>
  </PSlideover>
</template>
<script setup lang="ts">
import { ref } from 'vue'

const open = ref(false);
</script>

<template>
  <PSlideover
    v-model:open="open"
    title="Slideover with footer"
    description="This is useful when you want a form in a Slideover."
    :ui="{ footer: 'justify-end' }"
  >
    <PButton
      label="Open"
      color="neutral"
      variant="subtle"
    />

    <template #body>
      <Placeholder class="h-full" />
    </template>

    <template #footer="{ close }">
      <PButton
        label="Cancel"
        color="neutral"
        variant="outline"
        @click="close"
      />
      <PButton
        label="Submit"
        color="neutral"
      />
    </template>
  </PSlideover>
</template>

API

Props

Prop Default Type
title
string
description
string
content
DialogContentProps & Partial<EmitsToProps<DialogContentImplEmits>>

The content of the slideover.

overlay
true
boolean

Render an overlay behind the slideover.

transition
true
boolean

Animate the slideover when opening or closing.

side
'right'
"top" | "right" | "bottom" | "left"

The side of the slideover.

inset
false
boolean

Whether to inset the slideover from the edges.

portal
true
string | false | true | HTMLElement

Render the slideover in a portal.

close
true
boolean | Omit<ButtonProps, LinkPropsKeys>

Display a close button to dismiss the slideover. { size: 'md', color: 'neutral', variant: 'ghost' }

closeIcon
appConfig.ui.icons.close
any

The icon displayed in the close button.

dismissible
true
boolean

When false, the slideover will not close when clicking outside or pressing escape.

open
boolean

The controlled open state of the dialog. Can be binded as v-model:open.

defaultOpen
boolean

The open state of the dialog when it is initially rendered. Use when you do not need to control its open state.

modal
boolean

The modality of the dialog When set to true,
interaction with outside elements will be disabled and only dialog content will be visible to screen readers.

unmountOnHide
true
boolean

When set to false, the dialog content will not be unmounted when closed, but instead hidden with CSS.
Useful for SEO or when you want to improve performance by not remounting the component on every open.

ui
{ overlay?: SlotClass; content?: SlotClass; header?: SlotClass; wrapper?: SlotClass; body?: SlotClass; footer?: SlotClass; title?: SlotClass; description?: SlotClass; close?: SlotClass; }

Slots

Slot Type
default
{ open: boolean; }
content
{ close: () => void; }
header
{ close: () => void; }
title
{}
description
{}
actions
{}
close
{ ui: object; }
body
{ close: () => void; }
footer
{ close: () => void; }

Emits

Event Type
enter
[]
leave
[]
after:leave
[]
after:enter
[]
close:prevent
[]
update:open
[value: boolean]

Theme

app.config.ts
export default defineAppConfig({
  ui: {
    slideover: {
      slots: {
        overlay: 'fixed inset-0 bg-background-elevated/75',
        content: 'bg-background flex flex-col ring-border fixed divide-border divide-y focus:outline-none sm:ring sm:shadow-lg',
        header: 'p-4 flex gap-1.5 min-h-$ui-header-height items-center sm:px-6',
        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: 'color-text-highlighted font-600',
        description: 'mt-1 color-text-muted text-sm',
        close: 'absolute top-4 end-4'
      },
      variants: {
        side: {
          top: {
            content: ''
          },
          right: {
            content: 'sm:max-w-md'
          },
          bottom: {
            content: ''
          },
          left: {
            content: 'sm:max-w-md'
          }
        },
        inset: {
          true: {
            content: 'rounded-lg'
          }
        },
        transition: {
          true: {
            overlay: 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0'
          }
        }
      },
      compoundVariants: [
        {
          side: 'top',
          inset: true,
          class: {
            content: 'max-h-[calc(100%-2rem)] inset-x-4 top-4'
          }
        },
        {
          side: 'top',
          inset: false,
          class: {
            content: 'max-h-full inset-x-0 top-0'
          }
        },
        {
          side: 'right',
          inset: true,
          class: {
            content: 'w-[calc(100%-2rem)] inset-y-4 right-4'
          }
        },
        {
          side: 'right',
          inset: false,
          class: {
            content: 'w-full inset-y-0 right-0'
          }
        },
        {
          side: 'bottom',
          inset: true,
          class: {
            content: 'max-h-[calc(100%-2rem)] inset-x-4 bottom-4'
          }
        },
        {
          side: 'bottom',
          inset: false,
          class: {
            content: 'max-h-full inset-x-0 bottom-0'
          }
        },
        {
          side: 'left',
          inset: true,
          class: {
            content: 'w-[calc(100%-2rem)] inset-y-4 left-4'
          }
        },
        {
          side: 'left',
          inset: false,
          class: {
            content: 'w-full inset-y-0 left-0'
          }
        },
        {
          transition: true,
          side: 'top',
          class: {
            content: 'data-[state=open]:animate-in data-[state=open]:slide-in-from-top data-[state=closed]:animate-out data-[state=closed]:slide-out-to-top'
          }
        },
        {
          transition: true,
          side: 'right',
          class: {
            content: 'data-[state=open]:animate-in data-[state=open]:slide-in-from-right data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right'
          }
        },
        {
          transition: true,
          side: 'bottom',
          class: {
            content: 'data-[state=open]:animate-in data-[state=open]:slide-in-from-bottom data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom'
          }
        },
        {
          transition: true,
          side: 'left',
          class: {
            content: 'data-[state=open]:animate-in data-[state=open]:slide-in-left data-[state=closed]:animate-out data-[state=closed]:slide-out-left'
          }
        }
      ]
    }
  }
})
vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import ui from 'pohon-ui/vite'

export default defineConfig({
  plugins: [
    vue(),
    ui({
      ui: {
        slideover: {
          slots: {
            overlay: 'fixed inset-0 bg-background-elevated/75',
            content: 'bg-background flex flex-col ring-border fixed divide-border divide-y focus:outline-none sm:ring sm:shadow-lg',
            header: 'p-4 flex gap-1.5 min-h-$ui-header-height items-center sm:px-6',
            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: 'color-text-highlighted font-600',
            description: 'mt-1 color-text-muted text-sm',
            close: 'absolute top-4 end-4'
          },
          variants: {
            side: {
              top: {
                content: ''
              },
              right: {
                content: 'sm:max-w-md'
              },
              bottom: {
                content: ''
              },
              left: {
                content: 'sm:max-w-md'
              }
            },
            inset: {
              true: {
                content: 'rounded-lg'
              }
            },
            transition: {
              true: {
                overlay: 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0'
              }
            }
          },
          compoundVariants: [
            {
              side: 'top',
              inset: true,
              class: {
                content: 'max-h-[calc(100%-2rem)] inset-x-4 top-4'
              }
            },
            {
              side: 'top',
              inset: false,
              class: {
                content: 'max-h-full inset-x-0 top-0'
              }
            },
            {
              side: 'right',
              inset: true,
              class: {
                content: 'w-[calc(100%-2rem)] inset-y-4 right-4'
              }
            },
            {
              side: 'right',
              inset: false,
              class: {
                content: 'w-full inset-y-0 right-0'
              }
            },
            {
              side: 'bottom',
              inset: true,
              class: {
                content: 'max-h-[calc(100%-2rem)] inset-x-4 bottom-4'
              }
            },
            {
              side: 'bottom',
              inset: false,
              class: {
                content: 'max-h-full inset-x-0 bottom-0'
              }
            },
            {
              side: 'left',
              inset: true,
              class: {
                content: 'w-[calc(100%-2rem)] inset-y-4 left-4'
              }
            },
            {
              side: 'left',
              inset: false,
              class: {
                content: 'w-full inset-y-0 left-0'
              }
            },
            {
              transition: true,
              side: 'top',
              class: {
                content: 'data-[state=open]:animate-in data-[state=open]:slide-in-from-top data-[state=closed]:animate-out data-[state=closed]:slide-out-to-top'
              }
            },
            {
              transition: true,
              side: 'right',
              class: {
                content: 'data-[state=open]:animate-in data-[state=open]:slide-in-from-right data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right'
              }
            },
            {
              transition: true,
              side: 'bottom',
              class: {
                content: 'data-[state=open]:animate-in data-[state=open]:slide-in-from-bottom data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom'
              }
            },
            {
              transition: true,
              side: 'left',
              class: {
                content: 'data-[state=open]:animate-in data-[state=open]:slide-in-left data-[state=closed]:animate-out data-[state=closed]:slide-out-left'
              }
            }
          ]
        }
      }
    })
  ]
})

Changelog

No recent changes