UI Tour component to onboard users
16 Oct 2023

When comes to a “foreign” interface for users, particularly common in enterprise application, the users have to undergo training before they start using, wouldn’t it be nice if they can learn the main parts of the User Interface so as to get started quickly?

There are already a few libraries out there like Intro.js and Usetiful that does the onboarding. I decided to take up the challenge and build a simple one myself using VueJS, SVG and Floating UI.

Usage

I envisioned that it should be relatively simple and straight-forward to use this component in a declarative way. The following is a code snippet of how the component might be used.

<template>
  <div>
    <button @click="uiTour.start()">Start UI Tour</button>
    <section id="control-panel">...</section>
    <section id="chart">...</section>
    <section id="activity-log">...</section>
    <div></div>
  </div>

  <UITour ref="uiTour">
    <UITourStop
      v-slot="{ next, exit }"
      placement="right"
      target-element-id="control-panel"
    >
      <div>The control panel is the place to...</div>
      <button @click="next()">Next</button>
      <button @click="exit()">Exit</button>
    </UITourStop>
    <UITourStop v-slot="{ prev, next, exit }" placement="modal">
      <div>Thank you for learning with us!</div>
      <button @click="exit()">Exit</button>
    </UITourStop>
  </UITour>
</template>

UITour is the container that houses a list of UITourStop. This list will define the journey the user will go through in a step-by-step wizard manner. Each UITourStop has a prop to target the element it wants to spotlight on and provide the description of the target element via its default template slot. Optionally, the placement can be customized to where we might want it optimally, be default it is bottom. In addition, the slot props: prev, next and exit are provided for easy binding to buttons in the UITourStop template slot.

UI Tour component

Since the UITourStop child components are rendered programmatically based on the current active stop, we will have to use Render Function to accomplished that.

<script lang="ts">
import { computed, defineComponent, h, provide, ref, VNode } from "vue";

import { UI_TOUR_CONTEXT_KEY } from "./share";
import type { UITourExposed } from "./types";
import UITourInternal from "./UITourInternal.vue";
import UITourStop from "./UITourStop.vue";

export default defineComponent({
  props: {
    teleportTo: {
      type: String,
      required: false,
      default: "body",
    },
    spotlightPadding: {
      type: Number,
      required: false,
      default: 10,
    },
  },
  setup(props, { slots, expose }) {
    const currentIndex = ref(-1);

    let internalContainerVNode: VNode | null = null;

    const noOfStops = computed(() => slots.default?.().length ?? 0);

    function start() {
      addKeyDownListener();
      currentIndex.value = 0;
    }

    function prev() {
      currentIndex.value =
        (currentIndex.value - 1 + noOfStops.value) % noOfStops.value;
    }

    function next() {
      currentIndex.value = (currentIndex.value + 1) % noOfStops.value;
    }

    async function exit() {
      removeKeyDownListener();
      await internalContainerVNode?.component?.exposed?.exit();
      currentIndex.value = -1;
    }

    provide(UI_TOUR_CONTEXT_KEY, {
      prev,
      next,
      exit,
      currentIndex: computed(() => currentIndex.value),
      totalStops: noOfStops,
    });

    const instance: UITourExposed = { start };
    expose(instance);

    async function keyDownHandler(e: KeyboardEvent) {
      if (e.key === "ArrowRight") {
        next();
      } else if (e.key === "ArrowLeft") {
        prev();
      } else if (e.key === "Escape") {
        await exit();
      }
    }

    function addKeyDownListener() {
      window.addEventListener("keydown", keyDownHandler);
    }

    function removeKeyDownListener() {
      window.removeEventListener("keydown", keyDownHandler);
    }

    return () => {
      const children = slots.default?.();

      const stops = children?.filter((child) => child.type === UITourStop);
      const currentStop = stops?.[currentIndex.value] ?? h("div");
      const internalContainer = h(
        UITourInternal,
        {
          teleportTo: props.teleportTo,
          currentIndex: currentIndex.value,
          placement: currentStop.props?.placement,
          elementId: currentStop.props?.["target-element-id"],
          spotlightPadding: props.spotlightPadding,
        },
        () => [currentStop]
      );
      internalContainerVNode = internalContainer;
      return internalContainer;
    };
  },
});
</script>

One noteworthy point to highlight in the Render Function here is about getting the values from the child component. In order to do that, we got hold of the UITourStop’s vnode and access it via .props?.placement and .props?.["target-element-id"].

UITourInternal

In the above Render Function, you will notice there is another component we are rendering called UITourInternal. As you might have guessed it, it is the actual meat of the component doing the animation and displaying the floating UI content. As the template in UITourInternal component is quite complex and it is obviously unwieldy to have written all the template logic in the render function using h, we naturally extracted it to a child component and then bind the props from UITour to UITourInternal. In essence, UITour does the programmatical rendering of the UITourStop’s content, and UITourInternal does all the layout, positioning and animation.

<script setup lang="ts">
import type { Placement } from "@floating-ui/vue";
import {
  arrow,
  autoUpdate,
  flip,
  hide,
  offset,
  shift,
  useFloating,
} from "@floating-ui/vue";
import { useElementBounding, useElementSize } from "@vueuse/core";
import type { StyleValue } from "vue";
import { computed, nextTick, onMounted, ref, watch } from "vue";

import { assertIsDefined } from "@/share/assertHelpers";

const props = withDefaults(
  defineProps<{
    teleportTo?: string;
    currentIndex?: number;
    placement?: Placement | "modal";
    elementId?: string;
    spotlightPadding?: number;
  }>(),
  {
    teleportTo: "body",
    currentIndex: -1,
    placement: "bottom",
    elementId: undefined,
    spotlightPadding: 10,
  }
);

const overlay = ref<HTMLElement | null>(null);
const spotlight = ref<SVGRectElement | null>(null);
const currentTargetElement = ref<HTMLElement | null>(null);
const floating = ref<HTMLElement | null>(null);
const floatingArrow = ref<HTMLElement | null>(null);
const floatingContent = ref<HTMLElement | null>(null);
const modal = ref<HTMLElement | null>(null);
const showContent = ref(false);

const { top, left, width, height } = useElementBounding(currentTargetElement);

const { floatingStyles, middlewareData, placement } = useFloating(
  spotlight,
  floating,
  {
    placement: computed(() =>
      props.placement === "modal" ? undefined : props.placement
    ),
    middleware: [
      offset(10),
      flip({
        fallbackAxisSideDirection: "start",
      }),
      shift(),
      arrow({ element: floatingArrow }),
      hide(),
    ],
    whileElementsMounted: autoUpdate,
  }
);

const arrowPositionStyles = computed(() => {
  if (!middlewareData.value?.arrow) return {};
  const { x, y } = middlewareData.value.arrow;

  const side = placement.value.split("-")[0];

  const staticSide = {
    top: "bottom",
    right: "left",
    bottom: "top",
    left: "right",
  }[side];

  assertIsDefined(staticSide);
  if (!floatingArrow.value) return {};

  return {
    left: x != null ? `${x}px` : "",
    top: y != null ? `${y}px` : "",
    [staticSide]: `${-floatingArrow.value.offsetWidth / 2}px`,
  };
});

const hideFloatingStyles = computed(() => {
  const hideData = middlewareData.value.hide;
  return {
    visibility: hideData?.referenceHidden ? "hidden" : "visible",
  };
});

const computedFloatingStyles = computed(() => {
  return {
    ...floatingStyles.value,
    ...hideFloatingStyles.value,
  };
});

const spotLightBounds = computed(() => {
  return {
    x: left.value - props.spotlightPadding,
    y: top.value - props.spotlightPadding,
    width: width.value + props.spotlightPadding * 2,
    height: height.value + props.spotlightPadding * 2,
  };
});

const { width: overlayWidth, height: overlayHeight } = useElementSize(overlay);

const viewBox = computed(
  () => `0 0 ${overlayWidth.value} ${overlayHeight.value}`
);

function isInViewport(element: HTMLElement) {
  const rect = element.getBoundingClientRect();
  return (
    rect.top >= 0 &&
    rect.left >= 0 &&
    rect.bottom <=
      (window.innerHeight || document.documentElement.clientHeight) &&
    rect.right <= (window.innerWidth || document.documentElement.clientWidth)
  );
}

function awaitSpotlightTransitionEnd() {
  return new Promise((resolve) => {
    spotlight.value?.addEventListener("transitionend", resolve, {
      once: true,
    });
  });
}

async function exit() {
  currentTargetElement.value = overlay.value;
  showContent.value = false;
  await nextTick();
  spotlight.value?.classList.add("spotlight");
  await awaitSpotlightTransitionEnd();
  spotlight.value?.classList.remove("spotlight");
}

defineExpose({
  exit,
});

async function showFloatingContent() {
  const element = document.getElementById(props.elementId ?? "");
  if (element) {
    currentTargetElement.value = element;
    showContent.value = false;
    if (!isInViewport(currentTargetElement.value)) {
      currentTargetElement.value.scrollIntoView({
        behavior: "smooth",
        block: "center",
      });
    }
    await nextTick();
    spotlight.value?.classList.add("spotlight");
    await awaitSpotlightTransitionEnd();
    spotlight.value?.classList.remove("spotlight");
    showContent.value = false;
    await nextTick();
    showContent.value = true;
    await nextTick();
    floatingContent.value?.classList.add("show");
  }
}

async function showModalContent() {
  showContent.value = true;
  await nextTick();
  currentTargetElement.value = modal.value;
  await nextTick();
  spotlight.value?.classList.add("spotlight");
  await awaitSpotlightTransitionEnd();
  spotlight.value?.classList.remove("spotlight");
  modal.value?.classList.add("show");
}

watch(
  () => props.currentIndex,
  async (newVal) => {
    if (newVal === -1) {
      currentTargetElement.value = overlay.value;
      showContent.value = false;
      return;
    }
    if (props.placement !== "modal") {
      await showFloatingContent();
    } else {
      await showModalContent();
    }
  }
);

onMounted(() => {
  currentTargetElement.value = overlay.value;
});
</script>

<template>
  <Teleport :to="teleportTo">
    <div
      ref="overlay"
      class="fixed inset-0"
      :class="{ 'pointer-events-none': currentIndex === -1 }"
    >
      <svg v-if="currentIndex > -1" :viewBox="viewBox">
        <mask id="spotlight-mask">
          <rect
            x="0"
            y="0"
            :width="overlayWidth"
            :height="overlayHeight"
            fill="white"
          />
          <rect
            ref="spotlight"
            :x="spotLightBounds.x"
            :y="spotLightBounds.y"
            :width="spotLightBounds.width"
            :height="spotLightBounds.height"
            rx="10"
            fill="black"
          />
        </mask>
        <rect
          x="0"
          y="0"
          :width="overlayWidth"
          :height="overlayHeight"
          fill="black"
          fill-opacity="0.7"
          mask="url(#spotlight-mask)"
        />
      </svg>

      <div
        v-if="showContent && props.placement !== 'modal'"
        ref="floating"
        :style="computedFloatingStyles as StyleValue"
      >
        <div
          ref="floatingContent"
          class="floating-content"
          :data-placement="placement"
        >
          <slot />
          <div
            ref="floatingArrow"
            class="absolute h-[15px] w-[15px] rotate-45 bg-white"
            :style="arrowPositionStyles"
          ></div>
        </div>
      </div>

      <div
        v-if="showContent && props.placement === 'modal'"
        class="fixed inset-0 flex h-full items-center justify-center"
      >
        <div ref="modal" class="modal">
          <slot />
        </div>
      </div>

      <!-- debug -->
      <!-- <div
        class="absolute right-0 top-0 mr-auto border border-gray-300 bg-white p-4 text-xs"
      >
        <pre>{{ computedFloatingStyles }}</pre>
      </div> -->
    </div>
  </Teleport>
</template>

<style scoped>
.spotlight {
  transition: all 0.35s cubic-bezier(0.23, 1, 0.32, 1);
}

.btn {
  @apply rounded bg-gray-300 px-2 py-1 hover:bg-gray-200;
}

.floating-content {
  transition:
    transform 0.2s cubic-bezier(0.075, 0.82, 0.165, 1),
    opacity 0.2s linear;
}

.floating-content[data-placement="top"] {
  @apply -translate-y-5 opacity-0;
}
.floating-content[data-placement="bottom"] {
  @apply translate-y-5 opacity-0;
}
.floating-content[data-placement="left"] {
  @apply -translate-x-5 opacity-0;
}
.floating-content[data-placement="right"] {
  @apply translate-x-5 opacity-0;
}

.floating-content[data-placement="top"].show,
.floating-content[data-placement="right"].show,
.floating-content[data-placement="bottom"].show,
.floating-content[data-placement="left"].show {
  @apply translate-x-0 translate-y-0 opacity-100;
}

.modal {
  @apply opacity-0;
  transition: opacity 0.2s linear;
}
.modal.show {
  @apply scale-100 opacity-100;
}
</style>

Important points on the code:

  • SVG mask to achieve the spotlight effect.
  • VueUse’s useElementBounding and useElementSize composable to provide us the sizing and positioning of the target element
  • Floating UI to position the content of the UITourStop’s content
  • Added another mode (modal) to placement prop to have the ability to show just a modal without pointing to any UI element.

UITourStop

This component is pretty straightforward. Just passing the methods and computed properties from the parent UITour component as slot props to the consumer.

<script setup lang="ts">
import type { Placement } from "@floating-ui/vue";
import { defineSlots, inject } from "vue";

import { assertIsDefined } from "@/share/assertHelpers";

import { UI_TOUR_CONTEXT_KEY } from "./share";
import { UITourStopSlotProps } from "./types";

interface Props {
  targetElementId?: string;
  placement: Placement | "modal";
}
withDefaults(defineProps<Props>(), {
  targetElementId: undefined,
});

defineSlots<{
  default?: (props: UITourStopSlotProps) => any;
}>();

const context = inject(UI_TOUR_CONTEXT_KEY);

assertIsDefined(
  context,
  "UITour component must be the parent component to use UITourStop."
);

const { prev, next, exit, currentIndex } = context;
</script>

<template>
  <slot :prev="prev" :next="next" :exit="exit" :current-index="currentIndex" />
</template>

share.ts

This file only exposes the key for provide and inject. So pretty straightforward.

import type { InjectionKey } from "vue";

import { UITourContext } from "./types";

export const UI_TOUR_CONTEXT_KEY = Symbol(
  "UITourContext"
) as InjectionKey<UITourContext>;

types.ts

import type { Placement } from "@floating-ui/vue";
import { ComputedRef } from "vue";

export interface UITourExposed {
  start: () => void;
}

export interface UITourContext {
  prev(): void;
  next(): void;
  exit(): Promise<void>;
  currentIndex: ComputedRef<number>;
  totalStops: ComputedRef<number>;
}

export interface UITourStopContext {
  targetElementId: string;
  placement: Placement;
  renderContent: ((props: UITourStopSlotProps) => any) | undefined;
}

export interface UITourStopSlotProps {
  prev: () => void;
  next: () => void;
  exit: () => Promise<void>;
  currentIndex: number;
  totalStops: number;
}

Demo

You can see the demo here.

Conclusion

Creating a UI Tour component is not difficult as I thought. Leveraging libraries like Floating UI and VueUse makes it more easily achievable. SVG mask is also surprisingly quite intutive to use and implement. It was nice to know that SVG mask elements are able to animate using CSS.