Slide Panel
11 Aug 2022

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.