The component I am going to share today is a common pattern that we see in mobile and web apps: a panel that slides out, overlapping the main content, either from the left, right or bottom with a darkened backdrop. This UI pattern is typically used for menus, modals and confirmation dialogs.
Headless UI Library
One of the things we are leveraging from is Headless UI Library’s TransitionRoot
and TransitionChild
component. The nice thing about these 2 components is the ability to coordinate multiple transitions. The docs can be found here. The TransitionRoot
and TransitionChild
components are exactly what we need here. In the later part of the post we will see how we can use Vue’s native Transition
component instead of leveraging from @headlessui
.
Customizable options
Some of the options we want to provide as well for SlidePanel
is the ability to have it slide from either from the left, right or bottom. Also we bind the component’s $attrs
to the content panel so we can provide different width or height options to the drawer. In the slot, we also provide a convenient close()
slot prop as there will most likely be a close button on the content panel. In addition, we will also provide the opening
, opened
, closing
and closed
animation hooks for the consumer to do any custom logic when these events happen.
The Code
Do note that we are using TailwindCSS in the component itself. So the default values are set with TailwindCSS classes.
<script lang="ts">
import { TransitionChild, TransitionRoot } from "@headlessui/vue";
import { computed, defineComponent, ref } from "vue";
export default defineComponent({
inheritAttrs: false,
});
</script>
<script setup lang="ts">
interface Props {
side?: "bottom" | "left" | "right";
containerCss?: string;
backdropCss?: string;
}
const props = withDefaults(defineProps<Props>(), {
side: "bottom",
containerCss: "z-50",
backdropCss: "bg-slate-800/50",
});
interface Emits {
(e: "backdrop-clicked"): void;
(e: "opening"): void;
(e: "opened"): void;
(e: "closing"): void;
(e: "closed"): void;
}
defineEmits<Emits>();
const isOpened = ref(false);
function open() {
isOpened.value = true;
}
function close() {
isOpened.value = false;
}
defineExpose({
open,
close,
});
const computedCss = computed(() => {
let css = "";
switch (props.side) {
case "bottom":
css = "left-0 right-0 bottom-0";
break;
case "left":
css = "left-0 top-0 bottom-0";
break;
case "right":
css = "right-0 top-0 bottom-0";
break;
}
return css;
});
const computedTranslate = computed(() => {
let css = "";
switch (props.side) {
case "bottom":
css = "translate-y-full";
break;
case "left":
css = "-translate-x-full";
break;
case "right":
css = "translate-x-full";
break;
}
return css;
});
</script>
<template>
<TransitionRoot
:show="isOpened"
class="fixed inset-0"
:class="[containerCss]"
@before-enter="$emit('opening')"
@after-enter="$emit('opened')"
@before-leave="$emit('closing')"
@after-leave="$emit('closed')"
>
<TransitionChild
class="absolute inset-0"
:class="[backdropCss]"
enter="transition-opacity duration-300 ease-linear"
enter-from="opacity-0"
enter-to="opacity-100"
leave="transition-opacity duration-300 ease-linear"
leave-from="opacity-100"
leave-to="opacity-0"
@click.self="$emit('backdrop-clicked')"
></TransitionChild>
<TransitionChild
v-bind="$attrs"
class="absolute"
:class="[computedCss]"
enter="transition-transform duration-300 ease-in-out"
leave="transition-transform duration-300 ease-in-out"
:enter-from="computedTranslate"
:leave-to="computedTranslate"
>
<slot :close="close"></slot>
</TransitionChild>
</TransitionRoot>
</template>
Looking at the code, we leverage our CSS entirely using TailwindCSS classes which is nice!
Using SlidePanel
<script setup lang="ts">
import SlidePanel from "./SlidePanel.vue";
import { ref } from "vue";
const leftDrawer = ref<InstanceType<typeof SlidePanel>>();
function openLeftDrawer() {
leftDrawer.value?.open();
}
function closeLeftDrawer() {
leftDrawer.value?.close();
}
</script>
<template>
<div>
<button @click="openLeftDrawer">Open Left Drawer</button>
<SlidePanel
side="left"
ref="leftDrawer"
class="w-72 bg-white"
v-slot="{ close }"
@on-backdrop-clicked="closeLeftDrawer"
>
<div>
<button @click="close">Close Panel</button>
</div>
</SlidePanel>
</div>
</template>
We can customize the width of the panel by applying TailwindCSS classes like w-72
or calc(100%-18rem)
to the SlidePanel
component.
SlidePanel
Component in Action
Click on these buttons to see the drawers in action! Also try clicking on the backdrop to close the panel as well.
SlidePanel without @headlessui
library
I decided to challenge myself on building the SlidePanel component without using headlessui’s TransitionRoot
and TransitionChild
components but by just using Vue’s native Transition
component. If your project does not require the use of the other @headlessui
components, then consider checking out the following code.
Here is the code.
<script lang="ts">
import { computed, defineComponent, ref } from "vue";
export default defineComponent({
inheritAttrs: false,
});
</script>
<script setup lang="ts">
interface Props {
side?: "bottom" | "left" | "right";
containerCss?: string;
backdropCss?: string;
}
const props = withDefaults(defineProps<Props>(), {
side: "bottom",
containerCss: "",
backdropCss: "bg-slate-800/50",
});
interface Emits {
(e: "backdrop-clicked"): void;
(e: "opening"): void;
(e: "opened"): void;
(e: "closing"): void;
(e: "closed"): void;
}
defineEmits<Emits>();
const isOpened = ref(false);
function open() {
isOpened.value = true;
}
function close() {
isOpened.value = false;
}
defineExpose({
open,
close,
});
const computedCss = computed(() => {
let css = "";
switch (props.side) {
case "bottom":
css = "left-0 right-0 bottom-0";
break;
case "left":
css = "left-0 top-0 bottom-0";
break;
case "right":
css = "right-0 top-0 bottom-0";
break;
}
return css;
});
const computedTranslate = computed(() => {
let css = "";
switch (props.side) {
case "bottom":
css = "translate-y-full";
break;
case "left":
css = "-translate-x-full";
break;
case "right":
css = "translate-x-full";
break;
}
return css;
});
const computedTransformValue = computed(() => {
let transformValue = "none";
switch (props.side) {
case "bottom":
transformValue = "translate(0, 100%)";
break;
case "left":
transformValue = "translate(-100%, 0)";
break;
case "right":
transformValue = "translate(100%, 0)";
break;
}
return transformValue;
});
</script>
<template>
<Transition
name="slide-panel"
:duration="{ enter: 300, leave: 300 }"
@before-enter="$emit('opening')"
@after-enter="$emit('opened')"
@before-leave="$emit('closing')"
@after-leave="$emit('closed')"
>
<div v-if="isOpened" class="fixed inset-0" :class="[containerCss]">
<div
class="slide-panel-backdrop absolute inset-0 transition-opacity duration-300"
:class="[backdropCss]"
@click.self="$emit('backdrop-clicked')"
></div>
<div
v-bind="$attrs"
class="slide-panel-content absolute duration-300"
:class="[computedCss]"
:style="{ '--transform-value': computedTransformValue }"
>
<slot :close="close"></slot>
</div>
</div>
</Transition>
</template>
<style scoped>
.slide-panel-enter-from .slide-panel-backdrop,
.slide-panel-leave-to .slide-panel-backdrop {
opacity: 0;
}
.slide-panel-content {
transition-property: transform, width, height;
}
.slide-panel-enter-from .slide-panel-content,
.slide-panel-leave-to .slide-panel-content {
transform: var(--transform-value);
}
</style>
Here you can see we are using native CSS to point to the nested components: slide-panel-backdrop
and slide-panel-content
. We also leverage the use of CSS variable to determine the transform value: whether we should translate from the left, right or bottom. The important thing to note here is that you need to specify the duration
prop in the Transition
component as it doesn’t sniff the nested elements on when the transition ends.