Search

Ctrl + K

SpeedDial

This document outlines the steps to create SpeedDial component styled withTailwind CSS and using some npm dependency libraries.

Prerequisites

  • Node.js and npm installed on your machine.
  • Tailwind CSS installed in your project.
  • CVA(class-variance-authority) is a utility for managing CSS class names based on various conditions.
  • clsx is a tiny utility for constructing className strings conditionally.
Step 1: Create useClickAway hook
1// use-click-away.ts
2import { RefObject, useEffect } from "react";
3
4const defaultEvents = ["mousedown", "touchstart"];
5type DefaultEventType = "mousedown" | "touchstart";
6
7const useClickAway = (
8  ref: RefObject<HTMLElement | null>,
9  onClickAway: (event: MouseEvent | TouchEvent) => void,
10) => {
11  useEffect(() => {
12    const handler = (event: MouseEvent | TouchEvent) => {
13      const el = ref.current!;
14      if (!el) return;
15      if (el.contains(event.target as HTMLElement)) return;
16      onClickAway(event);
17    };
18
19    for (const eventName of defaultEvents) {
20      document.addEventListener(eventName as DefaultEventType, handler);
21    }
22
23    return () => {
24      for (const eventName of defaultEvents) {
25        document.removeEventListener(eventName as DefaultEventType, handler);
26      }
27    };
28    // eslint-disable-next-line react-hooks/exhaustive-deps
29  }, [ref]);
30};
31
32export default useClickAway;
33
Step 2: Create Tailwind config
1// tailwind.config.js
2module.exports = {
3  ...
4  theme: {
5    ...
6    extend: {
7      ...
8      keyframes: {
9        "scale-in": {
10          from: { transform: "scale(0)" },
11          to: { transform: "scale(1)" },
12        },
13        "scale-out": {
14          from: { transform: "scale(1)" },
15          to: { transform: "scale(0)" },
16        },
17      },
18      animation: {
19        "scale-in": "scale-in",
20        "scale-out": "scale-out",
21      },
22    },
23  },
24  plugins: [require("tailwindcss-animate")]
25};
26
Step 3: Create helper functions
1// speed-dial.helpers
2import { cva } from "class-variance-authority";
3
4export const wrapperVariants = cva(
5  "w-16 h-16 rounded-full shadow-md flex justify-center items-center cursor-pointer",
6  {
7    variants: {
8      color: {
9        primary: "bg-blue-500 text-white",
10        secondary: "bg-gray-500 text-white",
11        success: "bg-green-500 text-white",
12        danger: "bg-red-500 text-white",
13        warning: "bg-yellow-500 text-white",
14        info: "bg-cyan-500 text-white",
15        light: "bg-gray-200 text-black",
16        dark: "bg-gray-900 text-white",
17      },
18    },
19  },
20);
21
22export const itemVariants = cva("absolute items-center gap-2", {
23  variants: {
24    position: {
25      top: "flex flex-col-reverse inset-x-0 bottom-[calc(100%+0.5rem)]",
26      bottom: "flex flex-col inset-x-0 top-[calc(100%+0.5rem)]",
27      left: "flex flex-row-reverse inset-y-0 right-[calc(100%+0.5rem)]",
28      right: "flex inset-y-0 left-[calc(100%+0.5rem)]",
29    },
30  },
31});
32
Step 4: Create SpeedDial component
1// speed-dial.component.tsx
2import clsx from "clsx";
3import useClickAway from "@/hooks/use-click-away";
4import { PlusIcon } from "lucide-react";
5import React, { useEffect, useRef, useState } from "react";
6import { itemVariants, wrapperVariants } from "./speed-dial.helpers";
7import type { VariantProps } from "class-variance-authority";
8
9export interface SpeedDialProps
10  extends VariantProps<typeof wrapperVariants>,
11    VariantProps<typeof itemVariants> {
12  items: Array<{ icon: React.ReactNode; onClick: () => void }>;
13  className?: string;
14}
15
16const SpeedDial: React.FC<SpeedDialProps> = ({
17  items,
18  color = "primary",
19  position = "bottom",
20  className,
21}) => {
22  const [rendering, setRendering] = useState<boolean>(false);
23  const [opening, setOpening] = useState<boolean>(false);
24
25  const wrapperRef = useRef<HTMLDivElement>(null);
26
27  useClickAway(wrapperRef, () => setOpening(false));
28
29  useEffect(() => {
30    if (opening) setRendering(true);
31  }, [opening]);
32
33  const handleAnimationEnd = () => {
34    if (!opening) setRendering(false);
35  };
36
37  return (
38    <div className={cn("relative", className)}>
39      <div
40        ref={wrapperRef}
41        className={wrapperVariants({ color })}
42        onClick={() => setOpening(!opening)}
43      >
44        <PlusIcon
45          width={28}
46          height={28}
47          className={cn("transition-transform duration-150", {
48            "rotate-45": rendering,
49          })}
50        />
51      </div>
52      {rendering && (
53        <div className={itemVariants({ position })}>
54          {items.map((item, index) => (
55            <div
56              key={index}
57              style={{
58                animationDelay: `${(opening ? index : items.length - index) * 0.05}s`,
59                animationDuration: `${0.05 * items.length}s`,
60              }}
61              className={cn(
62                "h-12 w-12 select-none rounded-full fill-mode-forwards",
63                "bg-neutral-600 shadow-md hover:bg-neutral-700",
64                "flex cursor-pointer items-center justify-center",
65                opening
66                  ? "animate-scale-in scale-0"
67                  : "scale-1 animate-scale-out",
68              )}
69              onClick={item.onClick}
70              onAnimationEnd={
71                index === items.length - 1 ? handleAnimationEnd : undefined
72              }
73            >
74              {item.icon}
75            </div>
76          ))}
77        </div>
78      )}
79    </div>
80  );
81};
82
83export default SpeedDial;
84