This document outlines the steps to create SpeedDial
component styled withTailwind CSS and using some npm dependency libraries.
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.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
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
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
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