@marv3l/canopy-ui 1.0.3 → 1.1.0

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.
package/README.md CHANGED
@@ -1,261 +1,291 @@
1
- # Canopy UI
1
+ # <img src="./public/favicon.ico" width="48" height="48" align="center" /> Canopy UI
2
2
 
3
- > A modern, copy-and-paste CLI for adding customizable UI components directly to React and Next.js codebases.
3
+ <div align="center">
4
4
 
5
- Canopy UI installs component source code into your project, giving you full ownership over the markup, styles, behavior, and design tokens. Customize components as much as you need—without being locked into a hosted UI library.
5
+ ### Custom UI Components for React & Next.js
6
6
 
7
- ## Quick Start
7
+ A modern npm package for building beautiful, customizable user interfaces.
8
8
 
9
- Add a component with one command:
9
+ <br />
10
10
 
11
- ```bash
12
- npx canopy-ui add toast
13
- ```
11
+ [![npm](https://img.shields.io/badge/npm-CB3837?style=for-the-badge\&logo=npm\&logoColor=white)](https://www.npmjs.com/package/canopy-ui)
12
+ [![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge\&logo=typescript\&logoColor=white)](https://www.typescriptlang.org/)
13
+ [![React](https://img.shields.io/badge/React-20232A?style=for-the-badge\&logo=react\&logoColor=61DAFB)](https://react.dev/)
14
+ [![Next.js](https://img.shields.io/badge/Next.js-000000?style=for-the-badge\&logo=nextdotjs\&logoColor=white)](https://nextjs.org/)
14
15
 
15
- Run the command without a component name to open an interactive multi-select prompt:
16
+ <br />
16
17
 
17
- ```bash
18
- npx canopy-ui add
19
- ```
18
+ <a href="https://canopy-ui-web.vercel.app/">
19
+
20
+ <img src="https://img.shields.io/badge/Documentation-Canopy%20UI-7C3AED?style=for-the-badge&logo=readthedocs&logoColor=white" />
21
+
22
+ </a>
23
+
24
+ <a href="https://www.npmjs.com/package/canopy-ui">
25
+
26
+ <img src="https://img.shields.io/badge/npm-View%20Package-CB3837?style=for-the-badge&logo=npm&logoColor=white" />
27
+
28
+ </a>
29
+
30
+ <a href="https://github.com/ShawnR04/canopy-ui">
31
+
32
+ <img src="https://img.shields.io/badge/GitHub-Repository-181717?style=for-the-badge&logo=github&logoColor=white" />
33
+
34
+ </a>
35
+
36
+ </div>
37
+
38
+ <br />
20
39
 
21
40
  ---
22
41
 
23
- ## Components
42
+ ## <img src="https://api.iconify.design/lucide/book-open.svg?color=%233B82F6" width="25" /> About Canopy UI
43
+
44
+ **Canopy UI** is a customizable UI component library for **React** and **Next.js** applications.
45
+
46
+ The project is built around a simple idea: provide developers with modern, polished UI components that are easy to integrate and customize.
47
+
48
+ Canopy UI currently provides a **Toast notification component**, with additional components planned for the future.
49
+
50
+ > **Simple components. Flexible design. Built for your application.**
51
+
52
+ ---
53
+
54
+ ## <img src="https://api.iconify.design/lucide/layers-3.svg?color=%238B5CF6" width="25" /> Components
55
+
56
+ <div align="center">
57
+
58
+ <table>
59
+ <tr>
60
+
61
+ <td align="center" width="50%">
62
+
63
+ <img src="https://api.iconify.design/lucide/bell.svg?color=%23F59E0B" width="42" />
64
+
65
+ <br /><br />
66
+
67
+ <b>Toast</b>
68
+
69
+ <br />
70
+
71
+ Customizable notification component with configurable variants, colors, positioning, durations, and styling.
72
+
73
+ <br /><br />
74
+
75
+ <img src="https://img.shields.io/badge/Available-22C55E?style=flat-square&logoColor=white" />
76
+
77
+ </td>
78
+
79
+ <td align="center" width="50%">
80
+
81
+ <img src="https://api.iconify.design/lucide/sparkles.svg?color=%238B5CF6" width="42" />
82
+
83
+ <br /><br />
84
+
85
+ <b>More Coming Soon</b>
86
+
87
+ <br />
88
+
89
+ Canopy UI is actively growing with additional components planned for future releases.
90
+
91
+ <br /><br />
92
+
93
+ <img src="https://img.shields.io/badge/Coming%20Soon-8B5CF6?style=flat-square&logoColor=white" />
94
+
95
+ </td>
96
+
97
+ </tr>
98
+ </table>
99
+
100
+ </div>
101
+
102
+ ---
103
+
104
+ ## <img src="https://api.iconify.design/lucide/sparkles.svg?color=%23F59E0B" width="25" /> Features
105
+
106
+ <div align="center">
107
+
108
+ <table>
109
+ <tr>
110
+
111
+ <td align="center" width="25%">
112
+
113
+ <img src="https://api.iconify.design/lucide/palette.svg?color=%23EC4899" width="36" />
114
+
115
+ <br /><br />
116
+
117
+ <b>Customizable</b>
118
+
119
+ <br />
24
120
 
25
- ### Toast
121
+ Designed to adapt to your application's visual style.
26
122
 
27
- A customizable, animated notification system with:
123
+ </td>
28
124
 
29
- - Built-in `success` and `error` variants
30
- - Configurable global default duration
31
- - Configurable screen position
32
- - Per-toast duration overrides
33
- - Custom colors using hex values, CSS variables, or design tokens
34
- - Progress-bar color controls
35
- - Direct Tailwind `className` overrides
125
+ <td align="center" width="25%">
36
126
 
37
- ## Installation
127
+ <img src="https://api.iconify.design/lucide/code-2.svg?color=%238B5CF6" width="36" />
38
128
 
39
- Install the Toast component into your project:
129
+ <br /><br />
40
130
 
41
- ```bash
42
- npx canopy-ui add toast
43
- ```
131
+ <b>Developer Friendly</b>
44
132
 
45
- The component is added to your local UI directory, typically under:
133
+ <br />
46
134
 
47
- ```text
48
- components/ui/toast.tsx
49
- components/ui/use-toast.ts
50
- ```
135
+ Built with modern React and TypeScript.
51
136
 
52
- > The exact generated paths may depend on your project's configured import aliases.
137
+ </td>
138
+
139
+ <td align="center" width="25%">
140
+
141
+ <img src="https://api.iconify.design/lucide/zap.svg?color=%23F59E0B" width="36" />
142
+
143
+ <br /><br />
144
+
145
+ <b>Lightweight</b>
146
+
147
+ <br />
148
+
149
+ Focused components without unnecessary complexity.
150
+
151
+ </td>
152
+
153
+ <td align="center" width="25%">
154
+
155
+ <img src="https://api.iconify.design/lucide/settings-2.svg?color=%233B82F6" width="36" />
156
+
157
+ <br /><br />
158
+
159
+ <b>Flexible</b>
160
+
161
+ <br />
162
+
163
+ Designed to work with your existing application and design system.
164
+
165
+ </td>
166
+
167
+ </tr>
168
+ </table>
169
+
170
+ </div>
53
171
 
54
172
  ---
55
173
 
56
- ## Setup
57
-
58
- Mount `Toaster` once in your root `app/layout.tsx`. This provides the toast viewport for all routes in your application.
59
-
60
- ```tsx
61
- import type { Metadata } from "next";
62
- import { Geist, Geist_Mono } from "next/font/google";
63
-
64
- import "./globals.css";
65
- import { Toaster } from "@/components/ui/toast";
66
-
67
- const geistSans = Geist({
68
- variable: "--font-geist-sans",
69
- subsets: ["latin"],
70
- });
71
-
72
- const geistMono = Geist_Mono({
73
- variable: "--font-geist-mono",
74
- subsets: ["latin"],
75
- });
76
-
77
- export const metadata: Metadata = {
78
- title: "My App",
79
- description: "My application",
80
- };
81
-
82
- export default function RootLayout({
83
- children,
84
- }: Readonly<{
85
- children: React.ReactNode;
86
- }>) {
87
- return (
88
- <html
89
- lang="en"
90
- className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
91
- >
92
- <body className="flex min-h-full flex-col">
93
- {children}
94
- <Toaster defaultDuration={3500} position="top-center" />
95
- </body>
96
- </html>
97
- );
98
- }
99
- ```
100
-
101
- ### `Toaster` Props
102
-
103
- | Prop | Type | Description |
104
- | --- | --- | --- |
105
- | `defaultDuration` | `number` | Default time, in milliseconds, before a toast dismisses. Individual toasts can override it. |
106
- | `position` | `string` | Position of the toast viewport, for example `"top-center"`. |
174
+ ## <img src="https://api.iconify.design/lucide/book-open.svg?color=%233B82F6" width="25" /> Documentation
175
+
176
+ The complete Canopy UI documentation is available on the official documentation site.
177
+
178
+ It includes:
179
+
180
+ * Component installation
181
+ * Configuration
182
+ * Usage
183
+ * API references
184
+ * Customization
185
+ * Examples
186
+ * CLI documentation
187
+
188
+ <div align="center">
189
+
190
+ <a href="https://canopy-ui-web.vercel.app/">
191
+
192
+ <img src="https://img.shields.io/badge/Read%20the%20Documentation-7C3AED?style=for-the-badge&logo=readthedocs&logoColor=white" />
193
+
194
+ </a>
195
+
196
+ </div>
107
197
 
108
198
  ---
109
199
 
110
- ## Usage
200
+ ## <img src="https://api.iconify.design/lucide/layers.svg?color=%2306B6D4" width="25" /> Tech Stack
111
201
 
112
- Import `toast` inside a client component, then call it from an event handler or client-side action.
202
+ <div align="center">
113
203
 
114
- ```tsx
115
- "use client";
204
+ [![React](https://img.shields.io/badge/React-20232A?style=for-the-badge\&logo=react\&logoColor=61DAFB)](https://react.dev/)
205
+ [![Next.js](https://img.shields.io/badge/Next.js-000000?style=for-the-badge\&logo=nextdotjs\&logoColor=white)](https://nextjs.org/)
206
+ [![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge\&logo=typescript\&logoColor=white)](https://www.typescriptlang.org/)
207
+ [![npm](https://img.shields.io/badge/npm-CB3837?style=for-the-badge\&logo=npm\&logoColor=white)](https://www.npmjs.com/)
116
208
 
117
- import { toast } from "@/components/ui/use-toast";
209
+ </div>
118
210
 
119
- export default function Page() {
120
- return (
121
- <button
122
- onClick={() =>
123
- toast({
124
- variant: "success",
125
- title: "Changes saved",
126
- description: "Your preferences were updated successfully.",
127
- })
128
- }
129
- >
130
- Show toast
131
- </button>
132
- );
133
- }
134
- ```
211
+ ---
212
+
213
+ ## <img src="https://api.iconify.design/lucide/heart.svg?color=%23EC4899" width="25" /> Philosophy
214
+
215
+ Canopy UI is built around a simple principle:
216
+
217
+ > **Build beautiful UI without unnecessary complexity.**
218
+
219
+ Components should be easy to integrate, easy to customize, and flexible enough to fit into different applications.
220
+
221
+ Canopy UI aims to provide a growing collection of components that developers can rely on when building modern interfaces.
222
+
223
+ ---
224
+
225
+ ## <img src="https://api.iconify.design/lucide/rocket.svg?color=%23EF4444" width="25" /> Roadmap
226
+
227
+ Canopy UI currently includes:
228
+
229
+ * <img src="https://api.iconify.design/lucide/check-circle-2.svg?color=%2322C55E" width="18" /> Toast
230
+
231
+ Additional components are planned for future releases.
135
232
 
136
233
  ---
137
234
 
138
- ## Examples
139
-
140
- ### Success Toast
141
-
142
- ```tsx
143
- toast({
144
- variant: "success",
145
- title: "Changes saved",
146
- description: "Your preferences were updated successfully.",
147
- });
148
- ```
149
-
150
- ### Error Toast
151
-
152
- ```tsx
153
- toast({
154
- variant: "error",
155
- title: "Action failed",
156
- description: "Could not connect to the remote server.",
157
- });
158
- ```
159
-
160
- ### Custom Colors and Duration
161
-
162
- Use `customColor` to override the toast palette. Values can be literal CSS colors, such as hex values, or CSS custom properties such as `var(--primary)`.
163
-
164
- ```tsx
165
- toast({
166
- title: "Pro subscription unlocked",
167
- description: "Welcome to VIP perks and custom styling.",
168
- duration: 5000,
169
- customColor: {
170
- bg: "var(--card)",
171
- border: "var(--primary)",
172
- text: "var(--card-foreground)",
173
- icon: "var(--primary)",
174
- progress: "var(--primary)",
175
- },
176
- });
177
- ```
178
-
179
- ### Tailwind Class Override
180
-
181
- Use `className` when you want to apply direct Tailwind utility classes to an individual toast.
182
-
183
- ```tsx
184
- toast({
185
- title: "Tailwind classes applied",
186
- description: "Styled with direct className overrides.",
187
- duration: 3500,
188
- customColor: {
189
- progress: "var(--destructive)",
190
- },
191
- className: "border-destructive/40 bg-card text-primary",
192
- });
193
- ```
235
+ ## <img src="https://api.iconify.design/lucide/github.svg?color=%23181717" width="25" /> Repository
236
+
237
+ Canopy UI is open source and available on GitHub.
238
+
239
+ <div align="center">
240
+
241
+ <a href="https://github.com/ShawnR04/canopy-ui">
242
+
243
+ <img src="https://img.shields.io/badge/View%20Source%20Code-GitHub-181717?style=for-the-badge&logo=github&logoColor=white" />
244
+
245
+ </a>
246
+
247
+ </div>
194
248
 
195
249
  ---
196
250
 
197
- ## Toast API
198
-
199
- ```ts
200
- toast({
201
- variant?: "success" | "error";
202
- title?: string;
203
- description?: string;
204
- duration?: number;
205
- customColor?: {
206
- bg?: string;
207
- border?: string;
208
- text?: string;
209
- icon?: string;
210
- progress?: string;
211
- };
212
- className?: string;
213
- });
214
- ```
215
-
216
- | Option | Description |
217
- | --- | --- |
218
- | `variant` | Applies a built-in visual style, such as `"success"` or `"error"`. |
219
- | `title` | Primary toast message. |
220
- | `description` | Supporting text displayed below the title. |
221
- | `duration` | Dismiss timeout in milliseconds. Overrides `Toaster`’s `defaultDuration`. |
222
- | `customColor.bg` | Toast background color. |
223
- | `customColor.border` | Toast border color. |
224
- | `customColor.text` | Toast text color. |
225
- | `customColor.icon` | Toast icon color. |
226
- | `customColor.progress` | Toast progress-bar color. |
227
- | `className` | Tailwind or custom CSS classes applied directly to the toast. |
251
+ ## <img src="https://api.iconify.design/lucide/scale.svg?color=%2322C55E" width="25" /> License
252
+
253
+ Canopy UI is licensed under the **MIT License**.
254
+
255
+ Copyright © 2026 **Shawn Rimai**
228
256
 
229
257
  ---
230
258
 
231
- ## Development
259
+ <div align="center">
260
+
261
+ <img src="./public/favicon.ico" width="42" height="42" align="center" />
262
+
263
+ ### Canopy UI
264
+
265
+ **Custom UI. Simple integration. Flexible design.**
266
+
267
+ <br />
268
+
269
+ <a href="https://canopy-ui-web.vercel.app/">
232
270
 
233
- Build the CLI:
271
+ <img src="https://img.shields.io/badge/Documentation-Visit%20Canopy%20UI-7C3AED?style=for-the-badge&logo=readthedocs&logoColor=white" />
234
272
 
235
- ```bash
236
- npm run build
237
- ```
273
+ </a>
238
274
 
239
- Link the package locally:
275
+ <a href="https://www.npmjs.com/package/canopy-ui">
240
276
 
241
- ```bash
242
- npm link
243
- ```
277
+ <img src="https://img.shields.io/badge/npm-Canopy%20UI-CB3837?style=for-the-badge&logo=npm&logoColor=white" />
244
278
 
245
- Then, inside a sample React or Next.js project, install a component through the linked CLI:
279
+ </a>
246
280
 
247
- ```bash
248
- canopy-ui add toast
249
- ```
281
+ <a href="https://github.com/ShawnR04/canopy-ui">
250
282
 
251
- ## Publishing
283
+ <img src="https://img.shields.io/badge/GitHub-ShawnR04-181717?style=for-the-badge&logo=github&logoColor=white" />
252
284
 
253
- Publish the package to npm:
285
+ </a>
254
286
 
255
- ```bash
256
- npm publish --access public
257
- ```
287
+ <br /><br />
258
288
 
259
- ## License
289
+ <sub>Built by Shawn Rimai</sub>
260
290
 
261
- MIT © Shawn Rimai
291
+ </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marv3l/canopy-ui",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "An accessible, themable React UI component library built for fast-moving web applications.",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {
@@ -15,44 +15,68 @@ export interface ToasterProps {
15
15
  position?: "top-right" | "bottom-right" | "top-center" | "bottom-center" | "top-left" | "bottom-left";
16
16
  }
17
17
 
18
- // Visual preset configurations mapped to your semantic theme tokens
18
+ // Visual preset configurations mapped to semantic & dark-safe theme tokens
19
19
  const variantStyles: Record<
20
20
  string,
21
- { bg: string; border: string; text: string; progress: string; icon: any }
21
+ {
22
+ bg: string;
23
+ border: string;
24
+ title: string;
25
+ description: string;
26
+ progress: string;
27
+ iconColor: string;
28
+ badge: string;
29
+ icon: any;
30
+ }
22
31
  > = {
23
32
  default: {
24
- bg: "bg-card",
25
- border: "border-border",
26
- text: "text-card-foreground",
27
- progress: "bg-foreground/20",
33
+ bg: "bg-card dark:bg-neutral-900/95",
34
+ border: "border-border dark:border-neutral-700/80",
35
+ title: "text-neutral-900 dark:text-neutral-50",
36
+ description: "text-neutral-600 dark:text-neutral-200",
37
+ progress: "bg-neutral-900/20 dark:bg-neutral-400",
38
+ iconColor: "text-neutral-700 dark:text-neutral-300",
39
+ badge: "bg-neutral-200 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-100 border-neutral-300 dark:border-neutral-700",
28
40
  icon: null,
29
41
  },
30
42
  success: {
31
- bg: "bg-success-bg",
32
- border: "border-success/40",
33
- text: "text-success",
34
- progress: "bg-success",
43
+ bg: "bg-success-bg dark:bg-emerald-950/60",
44
+ border: "border-success/40 dark:border-emerald-500/50",
45
+ title: "text-emerald-950 dark:text-emerald-100",
46
+ description: "text-emerald-800 dark:text-emerald-200/90",
47
+ progress: "bg-emerald-600 dark:bg-emerald-400",
48
+ iconColor: "text-emerald-600 dark:text-emerald-400",
49
+ badge: "bg-emerald-100 text-emerald-900 dark:bg-emerald-900/80 dark:text-emerald-100 border-emerald-300 dark:border-emerald-700",
35
50
  icon: CheckCircle2,
36
51
  },
37
52
  error: {
38
- bg: "bg-destructive/10",
39
- border: "border-destructive/30",
40
- text: "text-destructive",
41
- progress: "bg-destructive",
53
+ bg: "bg-destructive/10 dark:bg-red-950/60",
54
+ border: "border-destructive/30 dark:border-red-500/50",
55
+ title: "text-red-950 dark:text-red-100",
56
+ description: "text-red-800 dark:text-red-200/90",
57
+ progress: "bg-destructive dark:bg-red-400",
58
+ iconColor: "text-red-600 dark:text-red-400",
59
+ badge: "bg-red-100 text-red-900 dark:bg-red-900/80 dark:text-red-100 border-red-300 dark:border-red-700",
42
60
  icon: AlertCircle,
43
61
  },
44
62
  warning: {
45
- bg: "bg-warning-bg",
46
- border: "border-warning/40",
47
- text: "text-warning",
48
- progress: "bg-warning",
63
+ bg: "bg-warning-bg dark:bg-amber-950/60",
64
+ border: "border-warning/40 dark:border-amber-500/50",
65
+ title: "text-amber-950 dark:text-amber-100",
66
+ description: "text-amber-800 dark:text-amber-200/90",
67
+ progress: "bg-amber-600 dark:bg-amber-400",
68
+ iconColor: "text-amber-600 dark:text-amber-400",
69
+ badge: "bg-amber-100 text-amber-900 dark:bg-amber-900/80 dark:text-amber-100 border-amber-300 dark:border-amber-700",
49
70
  icon: AlertTriangle,
50
71
  },
51
72
  info: {
52
- bg: "bg-accent",
53
- border: "border-primary/30",
54
- text: "text-accent-foreground",
55
- progress: "bg-primary",
73
+ bg: "bg-accent dark:bg-sky-950/60",
74
+ border: "border-primary/30 dark:border-sky-500/50",
75
+ title: "text-sky-950 dark:text-sky-100",
76
+ description: "text-sky-800 dark:text-sky-200/90",
77
+ progress: "bg-primary dark:bg-sky-400",
78
+ iconColor: "text-sky-600 dark:text-sky-400",
79
+ badge: "bg-sky-100 text-sky-900 dark:bg-sky-900/80 dark:text-sky-100 border-sky-300 dark:border-sky-700",
56
80
  icon: Info,
57
81
  },
58
82
  };
@@ -81,6 +105,46 @@ export function Toaster({ defaultDuration = 4000, position = "top-center" }: Toa
81
105
  transform: scaleX(0);
82
106
  }
83
107
  }
108
+ @keyframes toast-expand {
109
+ from {
110
+ grid-template-rows: 0fr;
111
+ }
112
+ to {
113
+ grid-template-rows: 1fr;
114
+ }
115
+ }
116
+ @keyframes toast-fade-in {
117
+ from {
118
+ opacity: 0;
119
+ transform: translateY(-4px);
120
+ }
121
+ to {
122
+ opacity: 1;
123
+ transform: translateY(0);
124
+ }
125
+ }
126
+ @keyframes toast-pop {
127
+ 0% {
128
+ transform: scale(0.6);
129
+ }
130
+ 50% {
131
+ transform: scale(1.2);
132
+ }
133
+ 100% {
134
+ transform: scale(1);
135
+ }
136
+ }
137
+ @keyframes toast-shake {
138
+ 0%, 100% {
139
+ transform: translateX(0);
140
+ }
141
+ 25% {
142
+ transform: translateX(-2px);
143
+ }
144
+ 75% {
145
+ transform: translateX(2px);
146
+ }
147
+ }
84
148
  `}</style>
85
149
 
86
150
  <div className={`fixed z-50 pointer-events-none flex flex-col gap-2 p-4 w-full max-w-sm ${positionClasses}`}>
@@ -109,13 +173,14 @@ function ToastElement({
109
173
  }) {
110
174
  const duration = toast.duration ?? defaultDuration;
111
175
 
176
+ // Reset lifespan timer whenever count or duration changes
112
177
  React.useEffect(() => {
113
178
  if (duration <= 0) return;
114
179
  const timer = setTimeout(() => {
115
180
  onDismiss();
116
181
  }, duration);
117
182
  return () => clearTimeout(timer);
118
- }, [duration, onDismiss]);
183
+ }, [duration, toast.count, onDismiss]);
119
184
 
120
185
  const variant = toast.variant || "default";
121
186
  const defaultStyle = variantStyles[variant] || variantStyles.default;
@@ -130,47 +195,87 @@ function ToastElement({
130
195
  // 2. Prevent default Tailwind classes from overriding user custom classes or inline styles
131
196
  const userHasBg = Boolean(toast.customColor?.bg || toast.className?.match(/(?:^|\s)bg-/));
132
197
  const userHasBorder = Boolean(toast.customColor?.border || toast.className?.match(/(?:^|\s)border-/));
133
- const userHasText = Boolean(toast.customColor?.text || toast.className?.match(/(?:^|\s)text-/));
134
198
 
135
199
  return (
136
200
  <div
137
201
  style={customInlineStyle}
138
- className={`pointer-events-auto relative overflow-hidden flex items-start gap-3 w-full p-4 rounded-[var(--radius-lg,0.625rem)] border shadow-lg transition-all duration-200 backdrop-blur-sm ${
202
+ className={`pointer-events-auto relative overflow-hidden flex items-start gap-3 w-full p-4 rounded-[var(--radius-lg,0.625rem)] border shadow-xl dark:shadow-2xl dark:shadow-black/70 dark:ring-1 dark:ring-white/10 backdrop-blur-md transition-all duration-300 ease-out ${
139
203
  !userHasBg ? defaultStyle.bg : ""
140
- } ${!userHasBorder ? defaultStyle.border : ""} ${
141
- !userHasText ? defaultStyle.text : ""
142
- } ${toast.className || ""}`}
204
+ } ${!userHasBorder ? defaultStyle.border : ""} ${toast.className || ""}`}
143
205
  >
144
206
  {/* Render icon if preset defines one */}
145
207
  {IconComponent && (
146
208
  <IconComponent
147
- className="w-5 h-5 mt-0.5 shrink-0"
209
+ className={`w-5 h-5 mt-0.5 shrink-0 ${defaultStyle.iconColor}`}
148
210
  style={{ color: toast.customColor?.icon }}
149
211
  />
150
212
  )}
151
213
 
152
214
  {/* Toast Content Area */}
153
- <div className="flex-1 text-sm space-y-1">
154
- {toast.title && <div className="font-semibold leading-tight">{toast.title}</div>}
215
+ <div className="flex-1 text-sm">
216
+ <div className="flex items-center gap-2">
217
+ {toast.title && (
218
+ <div className={`font-semibold leading-tight tracking-tight ${defaultStyle.title}`}>
219
+ {toast.title}
220
+ </div>
221
+ )}
222
+
223
+ {/* Duplication Counter / Max Badge */}
224
+ {toast.count > 1 && (
225
+ <span
226
+ key={`${toast.count}-${toast.maxReached}`}
227
+ style={{
228
+ animation: toast.maxReached
229
+ ? "toast-shake 200ms ease-in-out"
230
+ : "toast-pop 200ms ease-out",
231
+ }}
232
+ className={`inline-flex items-center justify-center px-1.5 py-0.5 text-[10px] font-bold rounded-full border shadow-sm transition-transform ${
233
+ defaultStyle.badge
234
+ } ${toast.maxReached ? "ring-1 ring-destructive/40" : ""}`}
235
+ >
236
+ {toast.maxReached ? `×${toast.count} (max)` : `×${toast.count}`}
237
+ </span>
238
+ )}
239
+ </div>
240
+
241
+ {/* Collapsible/Expanding Description Wrapper */}
155
242
  {toast.description && (
156
- <div className="opacity-90 leading-relaxed text-xs">
157
- {toast.description}
243
+ <div
244
+ className="grid overflow-hidden"
245
+ style={{
246
+ gridTemplateRows: "0fr",
247
+ animation: "toast-expand 250ms cubic-bezier(0.16, 1, 0.3, 1) 250ms forwards",
248
+ }}
249
+ >
250
+ <div className="overflow-hidden">
251
+ <div
252
+ className={`leading-relaxed text-xs pt-1 opacity-0 font-normal ${defaultStyle.description}`}
253
+ style={{
254
+ animation: "toast-fade-in 200ms ease-out 250ms forwards",
255
+ }}
256
+ >
257
+ {toast.description}
258
+ </div>
259
+ </div>
158
260
  </div>
159
261
  )}
160
- {toast.action && <div className="pt-1">{toast.action}</div>}
262
+
263
+ {/* Interactive Action Button Slot */}
264
+ {toast.action && <div className="pt-2">{toast.action}</div>}
161
265
  </div>
162
266
 
163
- {/* Close button */}
267
+ {/* Close Button */}
164
268
  <button
165
269
  onClick={onDismiss}
166
- className="p-1 rounded-md opacity-70 hover:opacity-100 hover:bg-foreground/10 transition-colors"
270
+ className="p-1 rounded-md opacity-70 hover:opacity-100 hover:bg-neutral-500/15 dark:hover:bg-white/10 transition-colors text-neutral-500 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white"
167
271
  >
168
272
  <X className="w-4 h-4" />
169
273
  </button>
170
274
 
171
- {/* Animated Lifespan Progress Bar */}
275
+ {/* Animated Lifespan Progress Bar (Resets on stack increment) */}
172
276
  {duration > 0 && (
173
277
  <div
278
+ key={toast.count}
174
279
  className={`absolute bottom-0 left-0 right-0 h-1 origin-left ${
175
280
  !toast.customColor?.progress ? defaultStyle.progress : ""
176
281
  }`}
@@ -1,4 +1,4 @@
1
- "use client"
1
+ "use client";
2
2
 
3
3
  // Import React to access state hooks and ReactNode type definitions
4
4
  import * as React from "react";
@@ -8,62 +8,70 @@ export type ToastVariant = "default" | "success" | "error" | "warning" | "info"
8
8
 
9
9
  // Interface defining all configurable parameters when triggering a toast notification
10
10
  export interface ToastOptions {
11
- // Unique identifier for toast; auto-generated if omitted
12
- id?: string;
13
- // Primary header text or React component
14
- title?: React.ReactNode;
15
- // Secondary descriptive message or details
16
- description?: React.ReactNode;
17
- // Optional action button or interactive element
18
- action?: React.ReactNode;
19
- // Visual style preset (success, error, warning, info, default, custom)
20
- variant?: ToastVariant;
21
- // Individual lifespan in milliseconds; overrides the global layout default
22
- duration?: number;
23
- // User-defined custom styling parameters for dynamic themes
24
- customColor?: {
25
- // Custom CSS background color (HEX, RGB, or HSL)
26
- bg?: string;
27
- // Custom text color
28
- text?: string;
29
- // Custom border stroke color
30
- border?: string;
31
- //Custom progress bar stroke color
32
- progress?: string;
33
- // Custom icon fill/stroke tint
34
- icon?: string;
35
- };
36
- // Additional Tailwind or custom CSS classes applied to toast container
37
- className?: string;
11
+ // Unique identifier for toast; auto-generated if omitted
12
+ id?: string;
13
+ // Primary header text or React component
14
+ title?: React.ReactNode;
15
+ // Secondary descriptive message or details
16
+ description?: React.ReactNode;
17
+ // Optional action button or interactive element
18
+ action?: React.ReactNode;
19
+ // Visual style preset (success, error, warning, info, default, custom)
20
+ variant?: ToastVariant;
21
+ // Individual lifespan in milliseconds; overrides the global layout default
22
+ duration?: number;
23
+ // Maximum number of duplicate stacks allowed for this toast (defaults to 5)
24
+ maxCount?: number;
25
+ // User-defined custom styling parameters for dynamic themes
26
+ customColor?: {
27
+ // Custom CSS background color (HEX, RGB, or HSL)
28
+ bg?: string;
29
+ // Custom text color
30
+ text?: string;
31
+ // Custom border stroke color
32
+ border?: string;
33
+ // Custom progress bar stroke color
34
+ progress?: string;
35
+ // Custom icon fill/stroke tint
36
+ icon?: string;
37
+ };
38
+ // Additional Tailwind or custom CSS classes applied to toast container
39
+ className?: string;
38
40
  }
39
41
 
40
42
  // Internal representation of an active toast item containing open state
41
43
  export interface ToastItem extends ToastOptions {
42
- // Guaranteed string ID for DOM key mapping
43
- id: string;
44
- // Boolean flag controlling entrance and exit animations
45
- open: boolean;
44
+ // Guaranteed string ID for DOM key mapping
45
+ id: string;
46
+ // Boolean flag controlling entrance and exit animations
47
+ open: boolean;
48
+ // Tracks duplicate trigger count
49
+ count: number;
50
+ // Indicates if maximum stack limit has been hit
51
+ maxReached?: boolean;
46
52
  }
47
53
 
48
54
  // Maximum number of visible toast cards on screen simultaneously
49
55
  const TOAST_LIMIT = 5;
56
+ // Default maximum stack limit for duplicate toast triggers
57
+ const DEFAULT_MAX_COUNT = 5;
50
58
  // Delay before removed toasts are completely purged from memory (allows exit transition)
51
- const TOAST_REMOVE_DELAY = 700;
59
+ const TOAST_REMOVE_DELAY = 0;
52
60
 
53
61
  // Discriminated union type representing all possible reducer actions
54
- type Action =
55
- // Adds a newly triggered toast to state
56
- | { type: "ADD_TOAST"; toast: ToastItem }
57
- // Modifies properties of an existing active toast
58
- | { type: "UPDATE_TOAST"; toast: Partial<ToastItem> }
59
- // Initiates dismiss sequence (triggers exit animation)
60
- | { type: "DISMISS_TOAST"; toastId?: string }
61
- // Purges toast object from memory after exit animation finishes
62
- | { type: "REMOVE_TOAST"; toastId?: string };
62
+ type Action =
63
+ // Adds a newly triggered toast to state
64
+ | { type: "ADD_TOAST"; toast: ToastItem }
65
+ // Modifies properties of an existing active toast
66
+ | { type: "UPDATE_TOAST"; toast: Partial<ToastItem> }
67
+ // Initiates dismiss sequence (triggers exit animation)
68
+ | { type: "DISMISS_TOAST"; toastId?: string }
69
+ // Purges toast object from memory after exit animation finishes
70
+ | { type: "REMOVE_TOAST"; toastId?: string };
63
71
 
64
72
  // Structure of global toast memory state
65
73
  interface State {
66
- toasts: ToastItem[];
74
+ toasts: ToastItem[];
67
75
  }
68
76
 
69
77
  // Monotonically increasing counter for collision-free ID generation
@@ -71,8 +79,8 @@ let count = 0;
71
79
 
72
80
  // Generates unique string identifiers for toast items
73
81
  function genId(): string {
74
- count = (count + 1) % Number.MAX_SAFE_INTEGER;
75
- return count.toString();
82
+ count = (count + 1) % Number.MAX_SAFE_INTEGER;
83
+ return count.toString();
76
84
  }
77
85
 
78
86
  // Map tracking active removal timers to prevent duplicate schedule queues
@@ -80,73 +88,108 @@ const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
80
88
 
81
89
  // Schedule the hard removal of a dismissed toast after its exit animation completes
82
90
  const addToRemoveQueue = (toastId: string) => {
83
- // If a timeout is already scheduled for this ID, skip to avoid duplicates
84
- if (toastTimeouts.has(toastId)) return;
85
-
86
- // Schedule state dispatch after delay
87
- const timeout = setTimeout(() => {
88
- // Clean up timeout reference from tracking map
89
- toastTimeouts.delete(toastId);
90
- // Dispatch removal action to purge from state
91
- dispatch({ type: "REMOVE_TOAST", toastId });
92
- }, TOAST_REMOVE_DELAY);
93
-
94
- // Store reference in tracking map
95
- toastTimeouts.set(toastId, timeout);
91
+ // If a timeout is already scheduled for this ID, skip to avoid duplicates
92
+ if (toastTimeouts.has(toastId)) return;
93
+
94
+ // Schedule state dispatch after delay
95
+ const timeout = setTimeout(() => {
96
+ // Clean up timeout reference from tracking map
97
+ toastTimeouts.delete(toastId);
98
+ // Dispatch removal action to purge from state
99
+ dispatch({ type: "REMOVE_TOAST", toastId });
100
+ }, TOAST_REMOVE_DELAY);
101
+
102
+ // Store reference in tracking map
103
+ toastTimeouts.set(toastId, timeout);
96
104
  };
97
105
 
98
106
  // Pure reducer function handling toast state transitions
99
107
  export const reducer = (state: State, action: Action): State => {
100
- switch (action.type) {
101
- case "ADD_TOAST":
102
- return {
103
- ...state,
104
- // Prepend new toast and enforce maximum visible limit
105
- toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
106
- };
107
-
108
- case "UPDATE_TOAST":
109
- return {
110
- ...state,
111
- // Map through toasts and merge updated properties onto target ID
112
- toasts: state.toasts.map((t) =>
113
- t.id === action.toast.id ? { ...t, ...action.toast } : t
114
- ),
115
- };
116
-
117
- case "DISMISS_TOAST": {
118
- const { toastId } = action;
119
-
120
- // If a specific ID is provided, schedule removal for only that toast
121
- if (toastId) {
122
- addToRemoveQueue(toastId);
123
- } else {
124
- // Otherwise schedule removal for all currently open toasts
125
- state.toasts.forEach((toast) => addToRemoveQueue(toast.id));
126
- }
127
-
128
- return {
129
- ...state,
130
- // Mark target toasts as closed to trigger CSS fade-out
131
- toasts: state.toasts.map((t) =>
132
- t.id === toastId || toastId === undefined ? { ...t, open: false } : t
133
- ),
134
- };
135
- }
136
-
137
- case "REMOVE_TOAST":
138
- // Clear entire array if no specific ID passed
139
- if (action.toastId === undefined) return { ...state, toasts: [] };
140
-
141
- return {
142
- ...state,
143
- // Filter out target toast from state memory
144
- toasts: state.toasts.filter((t) => t.id !== action.toastId),
145
- };
146
-
147
- default:
148
- return state;
108
+ switch (action.type) {
109
+ case "ADD_TOAST": {
110
+ // Find existing active toast with identical title, description, and variant
111
+ const existingIndex = state.toasts.findIndex(
112
+ (t) =>
113
+ t.open &&
114
+ t.title === action.toast.title &&
115
+ t.description === action.toast.description &&
116
+ (t.variant || "default") === (action.toast.variant || "default")
117
+ );
118
+
119
+ if (existingIndex !== -1) {
120
+ const existing = state.toasts[existingIndex];
121
+ const maxLimit = existing.maxCount ?? action.toast.maxCount ?? DEFAULT_MAX_COUNT;
122
+
123
+ // Cap the count at maxLimit
124
+ const newCount = Math.min(existing.count + 1, maxLimit);
125
+ const maxReached = existing.count + 1 >= maxLimit;
126
+
127
+ const updatedToast: ToastItem = {
128
+ ...existing,
129
+ ...action.toast,
130
+ id: existing.id,
131
+ count: newCount,
132
+ maxReached,
133
+ open: true,
134
+ };
135
+
136
+ // Remove old instance and push updated version to the front
137
+ const rest = state.toasts.filter((t) => t.id !== existing.id);
138
+ return {
139
+ ...state,
140
+ toasts: [updatedToast, ...rest].slice(0, TOAST_LIMIT),
141
+ };
142
+ }
143
+
144
+ return {
145
+ ...state,
146
+ // Prepend new toast and enforce maximum visible limit
147
+ toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
148
+ };
149
149
  }
150
+
151
+ case "UPDATE_TOAST":
152
+ return {
153
+ ...state,
154
+ // Map through toasts and merge updated properties onto target ID
155
+ toasts: state.toasts.map((t) =>
156
+ t.id === action.toast.id ? { ...t, ...action.toast } : t
157
+ ),
158
+ };
159
+
160
+ case "DISMISS_TOAST": {
161
+ const { toastId } = action;
162
+
163
+ // If a specific ID is provided, schedule removal for only that toast
164
+ if (toastId) {
165
+ addToRemoveQueue(toastId);
166
+ } else {
167
+ // Otherwise schedule removal for all currently open toasts
168
+ state.toasts.forEach((toast) => addToRemoveQueue(toast.id));
169
+ }
170
+
171
+ return {
172
+ ...state,
173
+ // Mark target toasts as closed to trigger CSS fade-out
174
+ toasts: state.toasts.map((t) =>
175
+ t.id === toastId || toastId === undefined ? { ...t, open: false } : t
176
+ ),
177
+ };
178
+ }
179
+
180
+ case "REMOVE_TOAST":
181
+ // Clear entire array if no specific ID passed
182
+ if (action.toastId === undefined) return { ...state, toasts: [] };
183
+
184
+ return {
185
+ ...state,
186
+ // Filter out target toast from state memory
187
+ toasts: state.toasts.filter((t) => t.id !== action.toastId),
188
+ };
189
+
190
+ default:
191
+ return state;
192
+ }
150
193
  };
151
194
 
152
195
  // Array of subscriber callbacks implementing the Observer pattern
@@ -157,58 +200,60 @@ let memoryState: State = { toasts: [] };
157
200
 
158
201
  // Dispatches actions to state and notifies all registered React hook subscribers
159
202
  function dispatch(action: Action) {
160
- // Update in-memory singleton state
161
- memoryState = reducer(memoryState, action);
162
- // Notify every mounted React component listener
163
- listeners.forEach((listener) => listener(memoryState));
203
+ // Update in-memory singleton state
204
+ memoryState = reducer(memoryState, action);
205
+ // Notify every mounted React component listener
206
+ listeners.forEach((listener) => listener(memoryState));
164
207
  }
165
208
 
166
209
  // Imperative toast function callable from anywhere (inside or outside React lifecycle)
167
210
  export function toast(props: ToastOptions) {
168
- // Use provided ID or generate a new unique identifier
169
- const id = props.id || genId();
170
-
171
- // Helper to dynamically update this specific toast
172
- const update = (updatedProps: ToastOptions) =>
173
- dispatch({ type: "UPDATE_TOAST", toast: { ...updatedProps, id } });
174
-
175
- // Helper to dismiss this specific toast
176
- const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
177
-
178
- // Dispatch action to push toast into visible queue
179
- dispatch({
180
- type: "ADD_TOAST",
181
- toast: {
182
- ...props,
183
- id,
184
- open: true,
185
- },
186
- });
187
-
188
- // Return control object allowing caller to dismiss or update toast programmatically
189
- return { id, dismiss, update };
211
+ // Use provided ID or generate a new unique identifier
212
+ const id = props.id || genId();
213
+
214
+ // Helper to dynamically update this specific toast
215
+ const update = (updatedProps: ToastOptions) =>
216
+ dispatch({ type: "UPDATE_TOAST", toast: { ...updatedProps, id } });
217
+
218
+ // Helper to dismiss this specific toast
219
+ const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
220
+
221
+ // Dispatch action to push toast into visible queue
222
+ dispatch({
223
+ type: "ADD_TOAST",
224
+ toast: {
225
+ ...props,
226
+ id,
227
+ open: true,
228
+ count: 1,
229
+ maxReached: false,
230
+ },
231
+ });
232
+
233
+ // Return control object allowing caller to dismiss or update toast programmatically
234
+ return { id, dismiss, update };
190
235
  }
191
236
 
192
237
  // Custom React hook subscribing components to real-time toast updates
193
238
  export function useToast() {
194
- // Local state synced with singleton memory state
195
- const [state, setState] = React.useState<State>(memoryState);
196
-
197
- // Register listener on mount; unregister on unmount
198
- React.useEffect(() => {
199
- listeners.push(setState);
200
- return () => {
201
- const index = listeners.indexOf(setState);
202
- if (index > -1) {
203
- listeners.splice(index, 1);
204
- }
205
- };
206
- }, []); // Empty array ensures registration only happens on mount/unmount
207
-
208
- // Expose current state, trigger function, and dismiss helper
209
- return {
210
- ...state,
211
- toast,
212
- dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
239
+ // Local state synced with singleton memory state
240
+ const [state, setState] = React.useState<State>(memoryState);
241
+
242
+ // Register listener on mount; unregister on unmount
243
+ React.useEffect(() => {
244
+ listeners.push(setState);
245
+ return () => {
246
+ const index = listeners.indexOf(setState);
247
+ if (index > -1) {
248
+ listeners.splice(index, 1);
249
+ }
213
250
  };
251
+ }, []); // Empty array ensures registration only happens on mount/unmount
252
+
253
+ // Expose current state, trigger function, and dismiss helper
254
+ return {
255
+ ...state,
256
+ toast,
257
+ dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
258
+ };
214
259
  }