@devadeboye/react-material-components 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ @import "tailwindcss";
2
+
3
+ /* src/base.css */
4
+ @theme inline { --animate-ripple: ripple 600ms linear; --color-surface-container-low: #f7f2fa; --color-surface-container-highest: #e6e0e9; --color-outline: #79747e; --color-primary: #65558F; --color-on-primary: #FFFFFF; --color-primary-container: #EADDFF; --color-on-primary-container: #21005D; --color-secondary-container: #E8DEF8; --color-on-secondary-container: #1D192B; --color-on-surface: #1D1B20; --color-on-surface-variant: #49454F; --shadow-elevation-1: 0px 1px 3px 1px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.3); --shadow-elevation-2: 0px 2px 6px 2px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.3); --radius-xl: 12px; --font-label-l: 500 14px/20px var(--font-sans); --font-label-m: 500 12px/16px var(--font-sans); --font-label-s: 500 11px/16px var(--font-sans); --font-title-m: 500 16px/24px var(--font-sans); @keyframes ripple { 0% { transform: scale(0); opacity: 0.35; } 100% { transform: scale(4); opacity: 0; } } }
5
+
6
+ /* src/styles.css */
7
+ /*# sourceMappingURL=styles.css.map */
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/base.css"],"sourcesContent":["@theme inline {\n --animate-ripple: ripple 600ms linear;\n\n /* M3 Surface Container Colors */\n --color-surface-container-low: #f7f2fa;\n --color-surface-container-highest: #e6e0e9;\n --color-outline: #79747e;\n \n /* M3 Colors */\n --color-primary: #65558F;\n --color-on-primary: #FFFFFF;\n --color-primary-container: #EADDFF;\n --color-on-primary-container: #21005D;\n --color-secondary-container: #E8DEF8;\n --color-on-secondary-container: #1D192B;\n --color-on-surface: #1D1B20;\n --color-on-surface-variant: #49454F;\n\n /* M3 Elevations */\n --shadow-elevation-1: 0px 1px 3px 1px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.3);\n --shadow-elevation-2: 0px 2px 6px 2px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.3);\n\n /* M3 Corner Radius */\n --radius-xl: 12px;\n\n /* M3 Typography (Simplified for demo) */\n --font-label-l: 500 14px/20px var(--font-sans);\n --font-label-m: 500 12px/16px var(--font-sans);\n --font-label-s: 500 11px/16px var(--font-sans);\n --font-title-m: 500 16px/24px var(--font-sans);\n\n @keyframes ripple {\n 0% {\n transform: scale(0);\n opacity: 0.35;\n }\n 100% {\n transform: scale(4);\n opacity: 0;\n }\n }\n}\n"],"mappings":";;;AAAA,OAAO,OAAO,EACZ,gBAAgB,EAAE,OAAO,MAAM,MAAM,EAGrC,6BAA6B,EAAE,OAAO,EACtC,iCAAiC,EAAE,OAAO,EAC1C,eAAe,EAAE,OAAO,EAGxB,eAAe,EAAE,OAAO,EACxB,kBAAkB,EAAE,OAAO,EAC3B,yBAAyB,EAAE,OAAO,EAClC,4BAA4B,EAAE,OAAO,EACrC,2BAA2B,EAAE,OAAO,EACpC,8BAA8B,EAAE,OAAO,EACvC,kBAAkB,EAAE,OAAO,EAC3B,0BAA0B,EAAE,OAAO,EAGnC,oBAAoB,EAAE,IAAI,IAAI,IAAI,IAAI,KAAK,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,IAAI,IAAI,IAAI,IAAI,KAAK,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,IAAI,EAC7F,oBAAoB,EAAE,IAAI,IAAI,IAAI,IAAI,KAAK,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,IAAI,IAAI,IAAI,IAAI,KAAK,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,IAAI,EAG7F,WAAW,EAAE,IAAI,EAGjB,cAAc,EAAE,IAAI,IAAI,CAAC,KAAK,IAAI,YAAY,EAC9C,cAAc,EAAE,IAAI,IAAI,CAAC,KAAK,IAAI,YAAY,EAC9C,cAAc,EAAE,IAAI,IAAI,CAAC,KAAK,IAAI,YAAY,EAC9C,cAAc,EAAE,IAAI,IAAI,CAAC,KAAK,IAAI,YAAY,EAE9C,WAAW,OAAO,EAChB,GAAG,EACD,SAAS,EAAE,MAAM,EAAE,EACnB,OAAO,EAAE,IAAI,IAEf,KAAK,EACH,SAAS,EAAE,MAAM,EAAE,EACnB,OAAO,EAAE,CAAC;","names":[]}
@@ -0,0 +1,2 @@
1
+
2
+ export { }
@@ -0,0 +1,2 @@
1
+
2
+ export { }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@devadeboye/react-material-components",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "sideEffects": false,
5
5
  "license": "MIT",
6
6
  "files": [
7
- "dist"
7
+ "dist",
8
+ "src"
8
9
  ],
9
10
  "main": "./dist/index.js",
10
11
  "module": "./dist/index.mjs",
@@ -14,7 +15,8 @@
14
15
  "types": "./dist/index.d.ts",
15
16
  "import": "./dist/index.mjs",
16
17
  "require": "./dist/index.js"
17
- }
18
+ },
19
+ "./styles.css": "./dist/styles.css"
18
20
  },
19
21
  "peerDependencies": {
20
22
  "react": ">=19",
package/src/base.css ADDED
@@ -0,0 +1,42 @@
1
+ @theme inline {
2
+ --animate-ripple: ripple 600ms linear;
3
+
4
+ /* M3 Surface Container Colors */
5
+ --color-surface-container-low: #f7f2fa;
6
+ --color-surface-container-highest: #e6e0e9;
7
+ --color-outline: #79747e;
8
+
9
+ /* M3 Colors */
10
+ --color-primary: #65558F;
11
+ --color-on-primary: #FFFFFF;
12
+ --color-primary-container: #EADDFF;
13
+ --color-on-primary-container: #21005D;
14
+ --color-secondary-container: #E8DEF8;
15
+ --color-on-secondary-container: #1D192B;
16
+ --color-on-surface: #1D1B20;
17
+ --color-on-surface-variant: #49454F;
18
+
19
+ /* M3 Elevations */
20
+ --shadow-elevation-1: 0px 1px 3px 1px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.3);
21
+ --shadow-elevation-2: 0px 2px 6px 2px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.3);
22
+
23
+ /* M3 Corner Radius */
24
+ --radius-xl: 12px;
25
+
26
+ /* M3 Typography (Simplified for demo) */
27
+ --font-label-l: 500 14px/20px var(--font-sans);
28
+ --font-label-m: 500 12px/16px var(--font-sans);
29
+ --font-label-s: 500 11px/16px var(--font-sans);
30
+ --font-title-m: 500 16px/24px var(--font-sans);
31
+
32
+ @keyframes ripple {
33
+ 0% {
34
+ transform: scale(0);
35
+ opacity: 0.35;
36
+ }
37
+ 100% {
38
+ transform: scale(4);
39
+ opacity: 0;
40
+ }
41
+ }
42
+ }
@@ -0,0 +1,131 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { fn } from "@storybook/test";
3
+ import { Button } from ".";
4
+
5
+ const meta = {
6
+ title: "Components/Button",
7
+ component: Button,
8
+ parameters: {
9
+ layout: "centered",
10
+ },
11
+ tags: ["autodocs"],
12
+ argTypes: {
13
+ variant: {
14
+ control: "select",
15
+ options: ["filled", "tonal", "outlined", "text", "elevated"],
16
+ },
17
+ size: {
18
+ control: "radio",
19
+ options: ["xs", "s", "m", "l", "xl"],
20
+ },
21
+ shape: {
22
+ control: "radio",
23
+ options: ["round", "square"],
24
+ },
25
+ disabled: {
26
+ control: "boolean",
27
+ },
28
+ fullWidth: {
29
+ control: "boolean",
30
+ },
31
+ },
32
+ args: {
33
+ onClick: fn(),
34
+ },
35
+ } satisfies Meta<typeof Button>;
36
+
37
+ export default meta;
38
+ type Story = StoryObj<typeof meta>;
39
+
40
+ export const Filled: Story = {
41
+ args: {
42
+ variant: "filled",
43
+ children: "Filled Button",
44
+ size: "m",
45
+ shape: "round",
46
+ },
47
+ };
48
+
49
+ export const Tonal: Story = {
50
+ args: {
51
+ variant: "tonal",
52
+ children: "Tonal Button",
53
+ size: "m",
54
+ shape: "round",
55
+ },
56
+ };
57
+
58
+ export const Outlined: Story = {
59
+ args: {
60
+ variant: "outlined",
61
+ children: "Outlined Button",
62
+ },
63
+ };
64
+
65
+ export const Text: Story = {
66
+ args: {
67
+ variant: "text",
68
+ children: "Text Button",
69
+ },
70
+ };
71
+
72
+ export const Small: Story = {
73
+ args: {
74
+ size: "xs",
75
+ children: "Small",
76
+ },
77
+ };
78
+
79
+ export const Large: Story = {
80
+ args: {
81
+ size: "l",
82
+ children: "Large",
83
+ shape: "round",
84
+ },
85
+ };
86
+
87
+ export const Disabled: Story = {
88
+ args: {
89
+ disabled: true,
90
+ children: "Disabled",
91
+ },
92
+ };
93
+
94
+ export const Elevated: Story = {
95
+ args: {
96
+ variant: "elevated",
97
+ children: "Elevated Button",
98
+ },
99
+ };
100
+
101
+ export const CustomColor: Story = {
102
+ args: {
103
+ variant: "filled",
104
+ className: "bg-orange-500 text-white hover:bg-orange-600",
105
+ children: "Custom Color",
106
+ },
107
+ };
108
+
109
+ const PlusIcon = (
110
+ <svg
111
+ xmlns="http://www.w3.org/2000/svg"
112
+ width="100%"
113
+ height="100%"
114
+ viewBox="0 0 24 24"
115
+ fill="none"
116
+ stroke="currentColor"
117
+ strokeWidth="2"
118
+ strokeLinecap="round"
119
+ strokeLinejoin="round"
120
+ >
121
+ <path d="M5 12h14"></path>
122
+ <path d="M12 5v14"></path>
123
+ </svg>
124
+ );
125
+
126
+ export const WithLeadingIcon: Story = {
127
+ args: {
128
+ children: "Add Item",
129
+ leadingIcon: PlusIcon,
130
+ },
131
+ };
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Buttons prompt most actions in a UI
3
+ * https://m3.material.io/components/buttons/specs
4
+ */
5
+
6
+ import React from "react";
7
+ import { cva, type VariantProps } from "class-variance-authority";
8
+ import { clsx, type ClassValue } from "clsx";
9
+ import { twMerge } from "tailwind-merge";
10
+ import { Ripple } from "../ripple";
11
+
12
+ // Utils
13
+ function cn(...inputs: ClassValue[]) {
14
+ return twMerge(clsx(inputs));
15
+ }
16
+
17
+ const buttonVariants = cva(
18
+ "relative inline-flex items-center justify-center gap-2 font-medium transition-all hover:cursor-pointer disabled:pointer-events-none disabled:opacity-50 overflow-hidden",
19
+ {
20
+ variants: {
21
+ variant: {
22
+ filled:
23
+ "bg-primary text-on-primary hover:bg-primary/90 shadow-sm hover:shadow-md",
24
+ tonal:
25
+ "bg-secondary-container text-on-secondary-container hover:bg-secondary-container/90",
26
+ outlined:
27
+ "border border-outline bg-transparent text-primary hover:bg-primary/10 active:bg-primary/10",
28
+ text: "bg-transparent text-primary hover:bg-primary/10 active:bg-primary/10",
29
+ elevated:
30
+ "bg-surface-container-low text-primary shadow-elevation-1 hover:shadow-elevation-2 hover:bg-surface-container-low/80",
31
+ },
32
+
33
+ size: {
34
+ /** extra small */
35
+ xs: "h-8 px-3 text-label-s",
36
+ /** small */
37
+ s: "h-9 px-3 text-label-l",
38
+ /** medium (M3 Default) */
39
+ m: "h-10 px-6 text-label-l",
40
+ /** large */
41
+ l: "h-12 px-8 text-label-l",
42
+ /** extra large */
43
+ xl: "h-14 px-10 text-title-m",
44
+ },
45
+
46
+
47
+ shape: {
48
+ round: "rounded-full",
49
+ square: "",
50
+ },
51
+ fullWidth: {
52
+ true: "w-full",
53
+ },
54
+ },
55
+
56
+ compoundVariants: [
57
+ {
58
+ shape: "square",
59
+ size: "xs",
60
+ className: "rounded-xl active:rounded-lg",
61
+ },
62
+ {
63
+ shape: "square",
64
+ size: "s",
65
+ className: "rounded-lg active:rounded-lg",
66
+ },
67
+ {
68
+ shape: "square",
69
+ size: "m",
70
+ className: "rounded-2xl active:rounded-xl",
71
+ },
72
+ {
73
+ shape: "square",
74
+ size: "l",
75
+ className: "rounded-[28px] active:rounded-2xl",
76
+ },
77
+ {
78
+ shape: "square",
79
+ size: "xl",
80
+ className: "rounded-[28px] active:rounded-2xl",
81
+ },
82
+ ],
83
+
84
+ defaultVariants: {
85
+ variant: "filled",
86
+ size: "m",
87
+ shape: "round",
88
+ },
89
+ }
90
+ );
91
+
92
+ export interface ButtonProps
93
+ extends
94
+ React.ButtonHTMLAttributes<HTMLButtonElement>,
95
+ VariantProps<typeof buttonVariants> {
96
+ asChild?: boolean;
97
+ leadingIcon?: React.ReactNode;
98
+ }
99
+
100
+ /** determines the gap between leading icon and button text */
101
+ const determineGapAndIconSize = (
102
+ size: "xs" | "s" | "m" | "l" | "xl" | null | undefined
103
+ ) => {
104
+ switch (size) {
105
+ case "xs":
106
+ return ["gap-1", "w-5 h-5"];
107
+ case "s":
108
+ return ["gap-2", "w-5 h-5"];
109
+ case "m":
110
+ return ["gap-2", "w-6 h-6"];
111
+ case "l":
112
+ return ["gap-3", "w-8 h-8"];
113
+ case "xl":
114
+ return ["gap-4", "w-10 h-10"];
115
+ default:
116
+ return ["gap-2", "w-6 h-6"];
117
+ }
118
+ };
119
+
120
+ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
121
+ (
122
+ {
123
+ className,
124
+ variant,
125
+ size,
126
+ shape,
127
+ fullWidth,
128
+ children,
129
+ leadingIcon,
130
+ ...props
131
+ },
132
+ ref
133
+ ) => {
134
+ let [gap, iconSize] = determineGapAndIconSize(size);
135
+ return (
136
+ <button
137
+ className={cn(
138
+ buttonVariants({ variant, size, shape, fullWidth, className })
139
+ )}
140
+ ref={ref}
141
+ {...props}
142
+ >
143
+ {/* State Layer (Hover/Reset) handled via hover:bg utilities or overlay below */}
144
+ <div className="absolute inset-0 z-0 bg-current opacity-0 transition-opacity hover:opacity-[0.08] focus:opacity-[0.12] active:opacity-[0.12]" />
145
+
146
+ <span
147
+ className={`relative z-10 flex items-center justify-center ${gap}`}
148
+ >
149
+ {leadingIcon && (
150
+ <div className={`flex items-center justify-center ${iconSize}`}>
151
+ {leadingIcon}
152
+ </div>
153
+ )}
154
+
155
+ {children}
156
+ </span>
157
+ <Ripple />
158
+ </button>
159
+ );
160
+ }
161
+ );
162
+ Button.displayName = "Button";
@@ -0,0 +1,109 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Card } from "./card";
3
+
4
+ const meta: Meta<typeof Card> = {
5
+ title: "Components/Card",
6
+ component: Card,
7
+ parameters: {
8
+ layout: "centered",
9
+ },
10
+ tags: ["autodocs"],
11
+ argTypes: {
12
+ variant: {
13
+ control: "select",
14
+ options: ["elevated", "filled", "outlined"],
15
+ },
16
+ padding: {
17
+ control: "select",
18
+ options: ["none", "sm", "md", "lg"],
19
+ },
20
+ radius: {
21
+ control: "select",
22
+ options: ["none", "sm", "md", "lg", "xl", "2xl"],
23
+ },
24
+ interactive: {
25
+ control: "boolean",
26
+ },
27
+ },
28
+ };
29
+
30
+ export default meta;
31
+ type Story = StoryObj<typeof Card>;
32
+
33
+ export const Elevated: Story = {
34
+ args: {
35
+ variant: "elevated",
36
+ children: (
37
+ <div className="flex flex-col gap-2">
38
+ <h3 className="text-xl font-medium">Elevated Card</h3>
39
+ <p className="text-gray-600 dark:text-gray-400">
40
+ This is an elevated card with a default shadow and color.
41
+ </p>
42
+ </div>
43
+ ),
44
+ className: "w-[300px]",
45
+ },
46
+ };
47
+
48
+ export const Filled: Story = {
49
+ args: {
50
+ variant: "filled",
51
+ children: (
52
+ <div className="flex flex-col gap-2">
53
+ <h3 className="text-xl font-medium">Filled Card</h3>
54
+ <p className="text-gray-600 dark:text-gray-400">
55
+ This is a filled card with a darker background.
56
+ </p>
57
+ </div>
58
+ ),
59
+ className: "w-[300px]",
60
+ },
61
+ };
62
+
63
+ export const Outlined: Story = {
64
+ args: {
65
+ variant: "outlined",
66
+ children: (
67
+ <div className="flex flex-col gap-2">
68
+ <h3 className="text-xl font-medium">Outlined Card</h3>
69
+ <p className="text-gray-600 dark:text-gray-400">
70
+ This is an outlined card with a thin border.
71
+ </p>
72
+ </div>
73
+ ),
74
+ className: "w-[300px]",
75
+ },
76
+ };
77
+
78
+ export const Interactive: Story = {
79
+ args: {
80
+ variant: "elevated",
81
+ interactive: true,
82
+ children: (
83
+ <div className="flex flex-col gap-2">
84
+ <h3 className="text-xl font-medium">Interactive Card</h3>
85
+ <p className="text-gray-600 dark:text-gray-400">
86
+ Click me to see the ripple effect and state layer!
87
+ </p>
88
+ </div>
89
+ ),
90
+ className: "w-[300px]",
91
+ onClick: () => alert("Card clicked!"),
92
+ },
93
+ };
94
+
95
+ export const CustomPadding: Story = {
96
+ args: {
97
+ variant: "filled",
98
+ padding: "lg",
99
+ children: (
100
+ <div className="flex flex-col gap-2">
101
+ <h3 className="text-xl font-medium">Large Padding</h3>
102
+ <p className="text-gray-600 dark:text-gray-400">
103
+ This card has extra padding for a more spacious feel.
104
+ </p>
105
+ </div>
106
+ ),
107
+ className: "w-[300px]",
108
+ },
109
+ };
@@ -0,0 +1,92 @@
1
+ import React from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+ import { clsx, type ClassValue } from "clsx";
4
+ import { twMerge } from "tailwind-merge";
5
+ import { Ripple } from "./ripple";
6
+
7
+ // Utils
8
+ function cn(...inputs: ClassValue[]) {
9
+ return twMerge(clsx(inputs));
10
+ }
11
+
12
+ const cardVariants = cva(
13
+ "relative overflow-hidden transition-all duration-200",
14
+ {
15
+ variants: {
16
+ variant: {
17
+ elevated:
18
+ "bg-surface-container-low shadow-elevation-1 hover:shadow-elevation-2 dark:bg-gray-900 dark:text-gray-100",
19
+ filled:
20
+ "bg-surface-container-highest dark:bg-gray-800 dark:text-gray-100",
21
+ outlined:
22
+ "border border-outline bg-transparent dark:border-gray-700 dark:text-gray-100",
23
+ },
24
+ padding: {
25
+ none: "p-0",
26
+ sm: "p-4",
27
+ md: "p-6",
28
+ lg: "p-8",
29
+ },
30
+ radius: {
31
+ none: "rounded-none",
32
+ sm: "rounded-sm",
33
+ md: "rounded-md",
34
+ lg: "rounded-lg",
35
+ xl: "rounded-xl", // M3 default (12px)
36
+ "2xl": "rounded-2xl",
37
+ },
38
+ },
39
+ defaultVariants: {
40
+ variant: "elevated",
41
+ padding: "md",
42
+ radius: "xl",
43
+ },
44
+ }
45
+ );
46
+
47
+ export interface CardProps
48
+ extends
49
+ React.HTMLAttributes<HTMLDivElement>,
50
+ VariantProps<typeof cardVariants> {
51
+ interactive?: boolean;
52
+ }
53
+
54
+ export const Card = React.forwardRef<HTMLDivElement, CardProps>(
55
+ (
56
+ {
57
+ className,
58
+ variant,
59
+ padding,
60
+ radius,
61
+ interactive,
62
+ children,
63
+ onClick,
64
+ ...props
65
+ },
66
+ ref
67
+ ) => {
68
+ const isInteractive = interactive || !!onClick;
69
+
70
+ return (
71
+ <div
72
+ ref={ref}
73
+ onClick={onClick}
74
+ className={cn(
75
+ cardVariants({ variant, padding, radius, className }),
76
+ isInteractive && "hover:cursor-pointer group"
77
+ )}
78
+ {...props}
79
+ >
80
+ {/* State Layer for interactive cards */}
81
+ {isInteractive && (
82
+ <div className="absolute inset-0 z-0 bg-current opacity-0 transition-opacity group-hover:opacity-[0.08] group-focus:opacity-[0.12] group-active:opacity-[0.12]" />
83
+ )}
84
+
85
+ <div className="relative z-10 w-full h-full">{children}</div>
86
+
87
+ {isInteractive && <Ripple />}
88
+ </div>
89
+ );
90
+ }
91
+ );
92
+ Card.displayName = "Card";
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Floating action buttons (FABs) help people take primary actions.
3
+ * https://m3.material.io/components/floating-action-button/specs
4
+ */
5
+
6
+ import React from "react";
7
+ import { cva, type VariantProps } from "class-variance-authority";
8
+ import { Ripple } from "../ripple";
9
+ import { cn } from "../../../lib/utils/helpers";
10
+
11
+
12
+ const fabVariants = cva(
13
+ "relative inline-flex items-center justify-center transition-all hover:cursor-pointer disabled:pointer-events-none disabled:opacity-50 overflow-hidden",
14
+ {
15
+ variants: {
16
+ color: {
17
+ surface: "bg-white text-gray-900 dark:bg-gray-800 dark:text-gray-100",
18
+ primary: "bg-blue-100 text-blue-900 dark:bg-blue-900 dark:text-blue-100",
19
+ secondary: "bg-green-100 text-green-900 dark:bg-green-900 dark:text-green-100",
20
+ tertiary: "bg-pink-100 text-pink-900 dark:bg-pink-900 dark:text-pink-100",
21
+ },
22
+ size: {
23
+ small: "w-10 h-10 rounded-xl",
24
+ regular: "w-14 h-14 rounded-2xl",
25
+ medium: "w-20 h-20 rounded-3xl", // M3 Expressive
26
+ large: "w-24 h-24 rounded-[28px]",
27
+ },
28
+ variant: {
29
+ standard: "shadow-md hover:shadow-lg focus:shadow-md active:shadow-sm",
30
+ lowered: "shadow-sm hover:shadow-md focus:shadow-sm active:shadow-none",
31
+ },
32
+ },
33
+ defaultVariants: {
34
+ color: "surface",
35
+ size: "regular",
36
+ variant: "standard",
37
+ },
38
+ }
39
+ );
40
+
41
+ export interface FABProps
42
+ extends
43
+ Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "color">,
44
+ VariantProps<typeof fabVariants> {
45
+ children?: React.ReactNode;
46
+ }
47
+
48
+ const determineIconSize = (size: "small" | "regular" | "medium" | "large" | null | undefined) => {
49
+ switch (size) {
50
+ case "small":
51
+ return "w-6 h-6";
52
+ case "regular":
53
+ return "w-6 h-6";
54
+ case "medium":
55
+ return "w-7 h-7"; // 28x28dp
56
+ case "large":
57
+ return "w-9 h-9"; // 36x36dp
58
+ default:
59
+ return "w-6 h-6";
60
+ }
61
+ };
62
+
63
+ export const FAB = React.forwardRef<HTMLButtonElement, FABProps>(
64
+ ({ className, color, size, variant, children, ...props }, ref) => {
65
+ const iconSize = determineIconSize(size);
66
+ return (
67
+ <button
68
+ className={cn(fabVariants({ color, size, variant, className }))}
69
+ ref={ref}
70
+ {...props}
71
+ >
72
+ <span className={cn("relative z-10 flex items-center justify-center", iconSize)}>
73
+ {children}
74
+ </span>
75
+ <Ripple />
76
+ </button>
77
+ );
78
+ }
79
+ );
80
+
81
+ FAB.displayName = "FAB";
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Extended Floating Action Buttons (FABs) help people take primary actions.
3
+ * https://m3.material.io/components/extended-fab/specs
4
+ */
5
+
6
+ import React from "react";
7
+ import { cva, type VariantProps } from "class-variance-authority";
8
+ import { clsx, type ClassValue } from "clsx";
9
+ import { twMerge } from "tailwind-merge";
10
+ import { Ripple } from "../ripple";
11
+
12
+ // Utils
13
+ function cn(...inputs: ClassValue[]) {
14
+ return twMerge(clsx(inputs));
15
+ }
16
+
17
+ const extendedFabVariants = cva(
18
+ "relative inline-flex flex-row items-center justify-center transition-all hover:cursor-pointer disabled:pointer-events-none disabled:opacity-50 overflow-hidden h-14 w-fit rounded-2xl pr-5 pl-4 gap-2",
19
+ {
20
+ variants: {
21
+ color: {
22
+ surface: "bg-surface-container-high text-primary hover:bg-surface-container-highest", // M3 default for surface-colored FAB
23
+ primary: "bg-primary-container text-on-primary-container hover:bg-primary-container/90",
24
+ secondary: "bg-secondary-container text-on-secondary-container hover:bg-secondary-container/90",
25
+ tertiary: "bg-tertiary-container text-on-tertiary-container hover:bg-tertiary-container/90",
26
+ },
27
+ size: {
28
+ small: "h-14 rounded-2xl px-4 text-[16px] font-medium tracking-[0.15px]",
29
+ medium: "h-20 rounded-[20px] px-6.5 text-[22px] font-normal tracking-normal",
30
+ large: "h-24 rounded-[28px] px-7 text-2xl font-medium tracking-normal"
31
+ },
32
+ variant: {
33
+ standard: "shadow-md hover:shadow-lg focus:shadow-md active:shadow-sm",
34
+ lowered: "shadow-sm hover:shadow-md focus:shadow-sm active:shadow-none",
35
+ },
36
+ },
37
+ defaultVariants: {
38
+ color: "primary",
39
+ variant: "standard",
40
+ size: "medium"
41
+ },
42
+ }
43
+ );
44
+
45
+ export interface ExtendedFABProps
46
+ extends
47
+ Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "color">,
48
+ VariantProps<typeof extendedFabVariants> {
49
+ icon?: React.ReactNode;
50
+ label: string;
51
+ size?: "small" | "medium" | "large";
52
+ }
53
+
54
+ const determineGapAndIconSize = (
55
+ size: "small" | "medium" | "large" | null | undefined
56
+ ) => {
57
+ switch (size) {
58
+ case "small":
59
+ return ["gap-2", "w-6 h-6"];
60
+ case "medium":
61
+ return ["gap-3", "w-7 h-7"];
62
+ case "large":
63
+ return ["gap-4", "w-9 h-9"];
64
+ default:
65
+ return ["gap-3", "w-7 h-7"];
66
+ }
67
+ };
68
+
69
+ export const ExtendedFAB = React.forwardRef<HTMLButtonElement, ExtendedFABProps>(
70
+ ({ className, color, variant, icon, label, size, ...props }, ref) => {
71
+ let [gap, iconSize] = determineGapAndIconSize(size);
72
+
73
+ return (
74
+ <button
75
+ className={`${cn(extendedFabVariants({ color, variant, size, className }))} ${gap}`}
76
+ ref={ref}
77
+ {...props}
78
+ >
79
+ {/* State Layer (Hover/Reset) handled via hover:bg utilities */}
80
+ {icon && (
81
+ <span
82
+ className={`relative z-10 flex items-center justify-center ${iconSize}`}
83
+ >
84
+ {icon}
85
+ </span>
86
+ )}
87
+ <span className="relative z-10 font-medium text-sm tracking-wide">
88
+ {label}
89
+ </span>
90
+ <Ripple />
91
+ </button>
92
+ );
93
+ }
94
+ );
95
+
96
+ ExtendedFAB.displayName = "ExtendedFAB";
@@ -0,0 +1,101 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { FAB } from "./fab";
3
+
4
+ const meta: Meta<typeof FAB> = {
5
+ title: "Components/FAB",
6
+ component: FAB,
7
+ parameters: {
8
+ layout: "centered",
9
+ },
10
+ tags: ["autodocs"],
11
+ argTypes: {
12
+ color: {
13
+ control: "select",
14
+ options: ["surface", "primary", "secondary", "tertiary"],
15
+ },
16
+ size: {
17
+ control: "select",
18
+ options: ["small", "regular", "medium", "large"],
19
+ },
20
+ variant: {
21
+ control: "select",
22
+ options: ["standard", "lowered"],
23
+ },
24
+ },
25
+ };
26
+
27
+ export default meta;
28
+ type Story = StoryObj<typeof FAB>;
29
+
30
+ const PlusIcon = (
31
+ <svg
32
+ xmlns="http://www.w3.org/2000/svg"
33
+ width="100%"
34
+ height="100%"
35
+ viewBox="0 0 24 24"
36
+ fill="none"
37
+ stroke="currentColor"
38
+ strokeWidth="2"
39
+ strokeLinecap="round"
40
+ strokeLinejoin="round"
41
+ >
42
+ <path d="M5 12h14" />
43
+ <path d="M12 5v14" />
44
+ </svg>
45
+ );
46
+
47
+ export const Regular: Story = {
48
+ args: {
49
+ size: "regular",
50
+ children: PlusIcon,
51
+ },
52
+ };
53
+
54
+ export const Small: Story = {
55
+ args: {
56
+ size: "small",
57
+ children: PlusIcon,
58
+ },
59
+ };
60
+
61
+ export const MediumExpressive: Story = {
62
+ args: {
63
+ size: "medium",
64
+ children: PlusIcon,
65
+ },
66
+ };
67
+
68
+ export const Large: Story = {
69
+ args: {
70
+ size: "large",
71
+ children: PlusIcon,
72
+ },
73
+ };
74
+
75
+ export const Primary: Story = {
76
+ args: {
77
+ color: "primary",
78
+ children: PlusIcon,
79
+ },
80
+ };
81
+
82
+ export const Secondary: Story = {
83
+ args: {
84
+ color: "secondary",
85
+ children: PlusIcon,
86
+ },
87
+ };
88
+
89
+ export const Tertiary: Story = {
90
+ args: {
91
+ color: "tertiary",
92
+ children: PlusIcon,
93
+ },
94
+ };
95
+
96
+ export const Lowered: Story = {
97
+ args: {
98
+ variant: "lowered",
99
+ children: PlusIcon,
100
+ },
101
+ };
@@ -0,0 +1,44 @@
1
+ import { ExtendedFAB, ExtendedFABProps } from "../fabs/extended-fab";
2
+ import { FAB } from "../fabs/fab";
3
+ import { cn } from "../../../lib/utils/helpers";
4
+ import { clsx } from "clsx";
5
+ import { FabConfig } from "../../../types/navigation-rail";
6
+
7
+ interface ActionProps {
8
+ isExpanded: boolean;
9
+ fabConfig: FabConfig;
10
+ }
11
+
12
+ export const Action = ({ isExpanded, fabConfig }: ActionProps) => {
13
+ return (
14
+ <div
15
+ className={cn(
16
+ `px-4 py-2 flex-col ${clsx({ "items-center": !isExpanded })} min-h-14 transition-all duration-300`,
17
+ !isExpanded ? "hidden md:flex" : "flex"
18
+ )}
19
+ >
20
+ {isExpanded ? (
21
+ <ExtendedFAB
22
+ icon={fabConfig.icon}
23
+ label={fabConfig.label}
24
+ onClick={fabConfig.onClick}
25
+ color={fabConfig.color as ExtendedFABProps["color"]}
26
+ variant={fabConfig.variant}
27
+ size={"small"}
28
+ className={fabConfig.className}
29
+ />
30
+ ) : (
31
+ <FAB
32
+ size="regular" // Standard 56dp container
33
+ color={fabConfig.color}
34
+ variant={fabConfig.variant}
35
+ onClick={fabConfig.onClick}
36
+ aria-label={fabConfig.label}
37
+ className={fabConfig.className}
38
+ >
39
+ {fabConfig.icon}
40
+ </FAB>
41
+ )}
42
+ </div>
43
+ );
44
+ };
@@ -0,0 +1,126 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Navigation rails provide access to primary destinations in apps.
5
+ * https://m3.material.io/components/navigation-rail/overview
6
+ */
7
+
8
+ import React from "react";
9
+ import { cva } from "class-variance-authority";
10
+ import { clsx } from "clsx";
11
+ import {
12
+ NavRailProps,
13
+ NavRailDestinationConfig,
14
+ } from "../../../types/navigation-rail";
15
+ export type { NavRailProps, NavRailDestinationConfig };
16
+ import { NavigationRailDestination } from "./navigation-rail-destination";
17
+ import { cn } from "../../../lib/utils/helpers";
18
+ import { MenuButton } from "./menu-button";
19
+ import { Action } from "./action";
20
+
21
+ export const NavigationRail = ({
22
+ expanded: expandedProp,
23
+ destinations,
24
+ activeId,
25
+ showFab = false,
26
+ fabConfig,
27
+ showMenuButton = true,
28
+ onExpandedChange,
29
+ className,
30
+ alignment = "center",
31
+ backgroundColor = "bg-surface",
32
+ textColor = "text-on-surface hover:text-on-surface",
33
+ activeBgColor,
34
+ activeTextColor,
35
+ ...props
36
+ }: NavRailProps) => {
37
+ const [internalExpanded, setInternalExpanded] = React.useState(false);
38
+ const isExpanded = expandedProp ?? internalExpanded;
39
+
40
+ const handleToggle = () => {
41
+ const newState = !isExpanded;
42
+ if (expandedProp == null) {
43
+ setInternalExpanded(newState);
44
+ }
45
+ onExpandedChange?.(newState);
46
+ };
47
+
48
+ const visibleDestinations = destinations.filter((d) => !d.hidden).slice(0, 6);
49
+
50
+ // Handle backdrop click to close on mobile
51
+ const handleBackdropClick = () => {
52
+ if (expandedProp == null) {
53
+ setInternalExpanded(false);
54
+ }
55
+ onExpandedChange?.(false);
56
+ };
57
+
58
+ const navRailVariants = cva(
59
+ "flex flex-col transition-all duration-300 ease-[cubic-bezier(0.2,0,0,1)] z-50 scrollbar-none py-4",
60
+ {
61
+ variants: {
62
+ expanded: {
63
+ true: `fixed ${backgroundColor} inset-y-0 left-0 w-72 shadow-2xl h-dvh md:relative md:w-80 md:shadow-none`, // Mobile: Fixed Drawer. Desktop: Relative Rail.
64
+ false: `w-fit h-auto bg-transparent absolute md:relative md:w-24 md:${backgroundColor} md:h-dvh`, // Mobile: Just button. Desktop: Standard Rail.
65
+ },
66
+ },
67
+ defaultVariants: {
68
+ expanded: false,
69
+ },
70
+ }
71
+ );
72
+
73
+ return (
74
+ <>
75
+ {/* Mobile Backdrop */}
76
+ {isExpanded && (
77
+ <div
78
+ className="fixed inset-0 bg-black/40 z-40 md:hidden transition-opacity duration-300"
79
+ onClick={handleBackdropClick}
80
+ aria-hidden="true"
81
+ />
82
+ )}
83
+
84
+ <nav
85
+ className={cn(navRailVariants({ expanded: isExpanded, className }))}
86
+ {...props}
87
+ >
88
+ <div className="flex flex-col gap-2">
89
+ <MenuButton
90
+ isExpanded={isExpanded}
91
+ showMenuButton={showMenuButton}
92
+ handleToggle={handleToggle}
93
+ />
94
+
95
+ {/* FAB - Hidden on mobile collapsed */}
96
+ {showFab && fabConfig && (
97
+ <Action isExpanded={isExpanded} fabConfig={fabConfig} />
98
+ )}
99
+ </div>
100
+
101
+ {/* destinations - Hidden on mobile collapsed */}
102
+ <div
103
+ className={cn(
104
+ `flex-1 flex-col gap-1 py-4 ${clsx({ "justify-center": alignment === "center" })}`,
105
+ !isExpanded ? "hidden md:flex" : "flex"
106
+ )}
107
+ >
108
+ <div>
109
+ {visibleDestinations.map((dest) => (
110
+ <NavigationRailDestination
111
+ key={dest.id}
112
+ icon={dest.icon}
113
+ label={dest.label}
114
+ badge={dest.badge}
115
+ active={dest.id === activeId}
116
+ expanded={isExpanded}
117
+ onClick={dest.onClick}
118
+ href={dest.href}
119
+ />
120
+ ))}
121
+ </div>
122
+ </div>
123
+ </nav>
124
+ </>
125
+ );
126
+ };
@@ -0,0 +1,33 @@
1
+ import { MenuIcon } from "../../../lib/icons/menu-icon";
2
+ import { MenuOpenIcon } from "../../../lib/icons/menu-open-icon";
3
+ import { Ripple } from "../ripple";
4
+ import { clsx } from "clsx";
5
+
6
+ interface MenuButtonProps {
7
+ isExpanded: boolean;
8
+ showMenuButton?: boolean;
9
+ handleToggle: () => void;
10
+ }
11
+
12
+ export const MenuButton = ({
13
+ isExpanded,
14
+ showMenuButton = true,
15
+ handleToggle,
16
+ }: MenuButtonProps) => {
17
+ return (
18
+ <div
19
+ className={`p-4 flex flex-col ${clsx({ "items-center": !isExpanded, "block md:hidden": !showMenuButton })}`}
20
+ >
21
+ <button
22
+ onClick={handleToggle}
23
+ className="w-12 h-12 flex items-center justify-center rounded-full hover:bg-on-surface/10 transition-colors cursor-pointer relative overflow-hidden"
24
+ aria-label={isExpanded ? "Collapse navigation" : "Expand navigation"}
25
+ >
26
+ <div className="relative z-10">
27
+ {isExpanded ? <MenuOpenIcon /> : <MenuIcon />}
28
+ </div>
29
+ <Ripple />
30
+ </button>
31
+ </div>
32
+ );
33
+ };
@@ -0,0 +1,83 @@
1
+ import { cva } from "class-variance-authority";
2
+ import { Ripple } from "../ripple";
3
+ import { NavRailDestinationProps } from "../../../types/navigation-rail";
4
+ import {cn} from "../../../lib/utils/helpers";
5
+
6
+ const navItemVariants = cva(
7
+ "relative flex items-center group cursor-pointer transition-all duration-300 ease-[cubic-bezier(0.2,0,1)] px-4 py-3 outline-none focus-visible:outline-2 focus-visible:outline-primary w-fit",
8
+ {
9
+ variants: {
10
+ expanded: {
11
+ true: "flex-row gap-4 h-14 rounded-full mx-3",
12
+ false: "flex-col gap-1 h-auto min-h-14 justify-center",
13
+ },
14
+ },
15
+ defaultVariants: {
16
+ expanded: false,
17
+ },
18
+ }
19
+ );
20
+
21
+ export const NavigationRailDestination = ({
22
+ icon,
23
+ label,
24
+ active = false,
25
+ expanded = false,
26
+ badge,
27
+ activeBgColor = "bg-secondary-container",
28
+ activeTextColor = "text-on-secondary-container",
29
+ className,
30
+ href,
31
+ ...props
32
+ }: NavRailDestinationProps) => {
33
+ const Component = href ? "a" : "div";
34
+ const activeIndicatorVariants = cva(
35
+ "absolute rounded-full z-0 transition-all duration-300 ease-[cubic-bezier(0.2,0,0,1)]" +
36
+ ` ${activeBgColor} ${activeTextColor}`,
37
+ {
38
+ variants: {
39
+ active: {
40
+ true: "opacity-100",
41
+ false: "opacity-0 scale-x-50",
42
+ },
43
+ expanded: {
44
+ true: "inset-0 h-14 px-4 w-full",
45
+ false: "inset-x-0 mx-auto top-3 h-8 w-14", // 56dp indicator in collapsed
46
+ },
47
+ },
48
+ }
49
+ );
50
+
51
+ return (
52
+ <Component
53
+ className={cn(navItemVariants({ expanded, className }), "no-underline")}
54
+ href={href}
55
+ {...(props as any)}
56
+ >
57
+ <div className={cn(activeIndicatorVariants({ active, expanded }))} />
58
+
59
+ <div className="relative z-10 flex items-center justify-center w-14 h-8">
60
+ <div className="relative">
61
+ {icon}
62
+ {badge !== undefined && (
63
+ <span className="absolute -top-1 -right-1 flex h-4 min-w-4 px-1 items-center justify-center rounded-full bg-error text-on-error text-[10px] font-medium border-2 border-surface">
64
+ {badge}
65
+ </span>
66
+ )}
67
+ </div>
68
+ </div>
69
+
70
+ <span
71
+ className={cn(
72
+ "relative z-10 transition-all duration-300 font-medium text-center",
73
+ expanded
74
+ ? "text-base whitespace-nowrap overflow-hidden text-ellipsis"
75
+ : "text-xs"
76
+ )}
77
+ >
78
+ {label}
79
+ </span>
80
+ <Ripple />
81
+ </Component>
82
+ );
83
+ };
@@ -0,0 +1,123 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { NavigationRail, type NavRailDestinationConfig } from ".";
3
+ import { useState } from "react";
4
+
5
+ const meta: Meta<typeof NavigationRail> = {
6
+ title: "Components/NavigationRail",
7
+ component: NavigationRail,
8
+ parameters: {
9
+ layout: "fullscreen",
10
+ },
11
+ tags: ["autodocs"],
12
+ };
13
+
14
+ export default meta;
15
+
16
+ const HomeIcon = () => (
17
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
18
+ <path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
19
+ </svg>
20
+ );
21
+
22
+ const SearchIcon = () => (
23
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
24
+ <path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
25
+ </svg>
26
+ );
27
+
28
+ const InboxIcon = () => (
29
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
30
+ <path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5v-3h3.56c.69 1.19 1.97 2 3.44 2s2.75-.81 3.44-2H19v3zm0-5h-4.99c0 1.1-.9 2-2 2s-2-.9-2-2H5V5h14v9z" />
31
+ </svg>
32
+ );
33
+
34
+ const SettingsIcon = () => (
35
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
36
+ <path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
37
+ </svg>
38
+ );
39
+
40
+ const demoDestinations: NavRailDestinationConfig[] = [
41
+ { id: "home", icon: <HomeIcon />, label: "Home" },
42
+ { id: "search", icon: <SearchIcon />, label: "Search" },
43
+ { id: "inbox", icon: <InboxIcon />, label: "Inbox", badge: 3 },
44
+ { id: "settings", icon: <SettingsIcon />, label: "Settings" },
45
+ ];
46
+
47
+ export const Interactive: StoryObj<typeof NavigationRail> = {
48
+ render: () => {
49
+ const [active, setActive] = useState("home");
50
+
51
+ const destinationsWithClick = demoDestinations.map((d) => ({
52
+ ...d,
53
+ onClick: () => setActive(d.id),
54
+ }));
55
+
56
+ return (
57
+ <div className="flex h-screen bg-surface-container-low">
58
+ <NavigationRail destinations={destinationsWithClick} activeId={active} />
59
+ <main className="flex-1 p-8">
60
+ <h1 className="text-2xl font-bold">Content Area</h1>
61
+ <p className="mt-4 text-on-surface-variant">The menu button is built-in and state-aware.</p>
62
+ </main>
63
+ </div>
64
+ );
65
+ },
66
+ };
67
+
68
+ export const Collapsed: StoryObj<typeof NavigationRail> = {
69
+ args: {
70
+ expanded: false,
71
+ destinations: demoDestinations,
72
+ activeId: "home",
73
+ },
74
+ };
75
+
76
+ export const Expanded: StoryObj<typeof NavigationRail> = {
77
+ args: {
78
+ expanded: true,
79
+ destinations: demoDestinations,
80
+ activeId: "home",
81
+ },
82
+ };
83
+
84
+ export const HiddenSlots: StoryObj<typeof NavigationRail> = {
85
+ args: {
86
+ expanded: false,
87
+ destinations: [
88
+ ...demoDestinations,
89
+ { id: "hidden", icon: <SettingsIcon />, label: "Hidden", hidden: true },
90
+ ],
91
+ activeId: "home",
92
+ },
93
+ };
94
+
95
+ export const NoMenuButton: StoryObj<typeof NavigationRail> = {
96
+ args: {
97
+ showMenuButton: false,
98
+ destinations: demoDestinations,
99
+ activeId: "home",
100
+ },
101
+ };
102
+ export const WithLinks: StoryObj<typeof NavigationRail> = {
103
+ args: {
104
+ destinations: [
105
+ { id: "home", icon: <HomeIcon />, label: "Home", href: "/" },
106
+ { id: "search", icon: <SearchIcon />, label: "Search", href: "/search" },
107
+ {
108
+ id: "inbox",
109
+ icon: <InboxIcon />,
110
+ label: "Inbox",
111
+ badge: 3,
112
+ href: "/inbox",
113
+ },
114
+ {
115
+ id: "settings",
116
+ icon: <SettingsIcon />,
117
+ label: "Settings",
118
+ href: "/settings",
119
+ },
120
+ ],
121
+ activeId: "home",
122
+ },
123
+ };
@@ -0,0 +1,64 @@
1
+ "use client";
2
+
3
+ import React, { useState, useEffect } from "react";
4
+
5
+ // Types
6
+ interface RippleType {
7
+ x: number;
8
+ y: number;
9
+ size: number;
10
+ }
11
+
12
+ /**
13
+ * A material design ripple effect component.
14
+ * To use this, the parent container must have `relative` and `overflow-hidden`.
15
+ */
16
+ export const Ripple = () => {
17
+ const [ripples, setRipples] = useState<RippleType[]>([]);
18
+
19
+ useEffect(() => {
20
+ let bounce: NodeJS.Timeout | null = null;
21
+ if (ripples.length > 0) {
22
+ bounce = setTimeout(() => {
23
+ setRipples([]);
24
+ }, 600);
25
+ }
26
+ return () => {
27
+ if (bounce) clearTimeout(bounce);
28
+ };
29
+ }, [ripples.length]);
30
+
31
+ const addRipple = (event: React.MouseEvent<HTMLDivElement>) => {
32
+ const container = event.currentTarget.getBoundingClientRect();
33
+ const size =
34
+ container.width > container.height ? container.width : container.height;
35
+ const x = event.clientX - container.left - size / 2;
36
+ const y = event.clientY - container.top - size / 2;
37
+ const newRipple = { x, y, size };
38
+
39
+ setRipples((prev) => [...prev, newRipple]);
40
+ };
41
+
42
+ return (
43
+ <div
44
+ className="absolute inset-0 z-0 overflow-hidden rounded-[inherit]"
45
+ onMouseDown={addRipple}
46
+ >
47
+ {ripples.map((ripple, index) => {
48
+ return (
49
+ <span
50
+ key={index}
51
+ className="absolute animate-ripple rounded-full bg-white opacity-25"
52
+ style={{
53
+ top: ripple.y,
54
+ left: ripple.x,
55
+ width: ripple.size,
56
+ height: ripple.size,
57
+ transform: "scale(0)",
58
+ }}
59
+ />
60
+ );
61
+ })}
62
+ </div>
63
+ );
64
+ };
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export * from "./components/button";
2
+ export * from "./components/card";
3
+ export * from "./components/fabs/fab";
4
+ export * from "./components/navigation-rail";
5
+ // Ripple is internal-ish but we can export it if users want raw ripples
6
+ export { Ripple } from "./components/ripple";
7
+ export * from "./components/fabs/extended-fab";
8
+
9
+ // types
10
+ export * from "../types/navigation-rail";
package/src/styles.css ADDED
@@ -0,0 +1,2 @@
1
+ @import "tailwindcss";
2
+ @import "./base.css";