@vvedantb/ui 0.34.0 → 0.36.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.
Files changed (50) hide show
  1. package/README.md +50 -6
  2. package/package.json +10 -2
  3. package/src/ai-elements/activity-shared.tsx +1 -1
  4. package/src/ai-elements/activity-tasks.tsx +5 -19
  5. package/src/ai-elements/attachments.tsx +2 -2
  6. package/src/ai-elements/code-block.tsx +3 -1
  7. package/src/ai-elements/conversation.tsx +10 -4
  8. package/src/ai-elements/prompt-input.tsx +5 -4
  9. package/src/client.ts +3 -0
  10. package/src/icons/animated/animated-bell-icon.tsx +68 -0
  11. package/src/icons/animated/animated-counter.tsx +45 -0
  12. package/src/icons/animated/animated-key-icon.tsx +79 -0
  13. package/src/icons/animated/animated-notification-icon.tsx +202 -0
  14. package/src/icons/animated/animated-progress.tsx +74 -0
  15. package/src/icons/animated/animated-status-dot.tsx +98 -0
  16. package/src/icons/animated/index.ts +22 -0
  17. package/src/icons/animated/morphing-menu-icon.tsx +71 -0
  18. package/src/icons/animated/morphing-mic-icon.tsx +52 -0
  19. package/src/icons/animated/pulsing-rings.tsx +50 -0
  20. package/src/index.ts +3 -0
  21. package/src/logos/chrome-icon.tsx +29 -0
  22. package/src/logos/claude-icon.tsx +13 -0
  23. package/src/logos/convex-icon.tsx +21 -0
  24. package/src/logos/cursor-icon.tsx +10 -0
  25. package/src/logos/dropbox-icon.tsx +14 -0
  26. package/src/logos/figma-icon.tsx +26 -0
  27. package/src/logos/github-icon.tsx +10 -0
  28. package/src/logos/gmail-icon.tsx +34 -0
  29. package/src/logos/google-drive-icon.tsx +33 -0
  30. package/src/logos/index.ts +19 -0
  31. package/src/logos/linear-icon.tsx +13 -0
  32. package/src/logos/logo-svg.tsx +32 -0
  33. package/src/logos/notion-icon.tsx +19 -0
  34. package/src/logos/onedrive-icon.tsx +165 -0
  35. package/src/logos/sharepoint-icon.tsx +222 -0
  36. package/src/logos/slack-icon.tsx +25 -0
  37. package/src/logos/vercel-icon.tsx +10 -0
  38. package/src/ui/_menu-parts.tsx +1 -1
  39. package/src/ui/button-group.tsx +1 -1
  40. package/src/ui/clear-input.tsx +2 -2
  41. package/src/ui/command.tsx +2 -2
  42. package/src/ui/context-menu.tsx +3 -3
  43. package/src/ui/dropdown-menu.tsx +1 -1
  44. package/src/ui/hover-card.tsx +3 -3
  45. package/src/ui/input-group.tsx +4 -4
  46. package/src/ui/popover.tsx +3 -5
  47. package/src/ui/search-input.tsx +1 -1
  48. package/src/ui/select.tsx +5 -5
  49. package/src/ui/sonner.tsx +3 -0
  50. package/src/ui/tooltip.tsx +3 -3
@@ -0,0 +1,202 @@
1
+ import { m } from "motion/react";
2
+
3
+ export type NotificationIconType = "success" | "warning" | "error" | "info";
4
+
5
+ export interface AnimatedNotificationIconProps {
6
+ type: NotificationIconType;
7
+ /** Width and height in px. Default `20`. */
8
+ size?: number;
9
+ /** Stroke colour. Default: the type's token (`--color-success`, `--color-warning`, `--color-danger`, `--color-primary` for `info`). */
10
+ color?: string;
11
+ /** Class on the wrapping `span`. */
12
+ className?: string;
13
+ }
14
+
15
+ const TYPE_COLORS: Record<NotificationIconType, string> = {
16
+ success: "var(--color-success)",
17
+ warning: "var(--color-warning)",
18
+ error: "var(--color-danger)",
19
+ info: "var(--color-primary)",
20
+ };
21
+
22
+ interface PartProps {
23
+ size: number;
24
+ color: string;
25
+ }
26
+
27
+ function SuccessIcon({ size, color }: PartProps) {
28
+ return (
29
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
30
+ {/* Circle */}
31
+ <m.circle
32
+ cx="12"
33
+ cy="12"
34
+ r="10"
35
+ stroke={color}
36
+ strokeWidth="1.5"
37
+ fill="none"
38
+ initial={{ pathLength: 0, opacity: 0 }}
39
+ animate={{ pathLength: 1, opacity: 1 }}
40
+ transition={{ duration: 0.4, ease: "easeOut" }}
41
+ />
42
+ {/* Checkmark draws itself */}
43
+ <m.path
44
+ d="M8 12l2.5 2.5L16 9"
45
+ stroke={color}
46
+ strokeWidth="1.5"
47
+ strokeLinecap="round"
48
+ strokeLinejoin="round"
49
+ fill="none"
50
+ initial={{ pathLength: 0 }}
51
+ animate={{ pathLength: 1 }}
52
+ transition={{ duration: 0.3, delay: 0.3, ease: "easeOut" }}
53
+ />
54
+ </svg>
55
+ );
56
+ }
57
+
58
+ function WarningIcon({ size, color }: PartProps) {
59
+ return (
60
+ <m.svg
61
+ width={size}
62
+ height={size}
63
+ viewBox="0 0 24 24"
64
+ fill="none"
65
+ aria-hidden="true"
66
+ initial={{ rotate: 0 }}
67
+ animate={{ rotate: [0, -5, 5, -3, 3, 0] }}
68
+ transition={{ duration: 0.5, delay: 0.2 }}
69
+ >
70
+ {/* Triangle */}
71
+ <m.path
72
+ d="M12 2L2 20h20L12 2z"
73
+ stroke={color}
74
+ strokeWidth="1.5"
75
+ strokeLinecap="round"
76
+ strokeLinejoin="round"
77
+ fill="none"
78
+ initial={{ pathLength: 0, opacity: 0 }}
79
+ animate={{ pathLength: 1, opacity: 1 }}
80
+ transition={{ duration: 0.4, ease: "easeOut" }}
81
+ />
82
+ {/* Exclamation line */}
83
+ <m.path
84
+ d="M12 9v4"
85
+ stroke={color}
86
+ strokeWidth="1.5"
87
+ strokeLinecap="round"
88
+ initial={{ pathLength: 0 }}
89
+ animate={{ pathLength: 1 }}
90
+ transition={{ duration: 0.2, delay: 0.3 }}
91
+ />
92
+ {/* Exclamation dot */}
93
+ <m.circle
94
+ cx="12"
95
+ cy="17"
96
+ r="1"
97
+ fill={color}
98
+ initial={{ scale: 0 }}
99
+ animate={{ scale: 1 }}
100
+ transition={{ duration: 0.15, delay: 0.45, type: "spring" }}
101
+ />
102
+ </m.svg>
103
+ );
104
+ }
105
+
106
+ function ErrorIcon({ size, color }: PartProps) {
107
+ return (
108
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
109
+ {/* Circle */}
110
+ <m.circle
111
+ cx="12"
112
+ cy="12"
113
+ r="10"
114
+ stroke={color}
115
+ strokeWidth="1.5"
116
+ fill="none"
117
+ initial={{ scale: 0.8, opacity: 0 }}
118
+ animate={{ scale: 1, opacity: 1 }}
119
+ transition={{ duration: 0.3, ease: "easeOut" }}
120
+ />
121
+ {/* X, first stroke */}
122
+ <m.path
123
+ d="M15 9l-6 6"
124
+ stroke={color}
125
+ strokeWidth="1.5"
126
+ strokeLinecap="round"
127
+ initial={{ pathLength: 0 }}
128
+ animate={{ pathLength: 1 }}
129
+ transition={{ duration: 0.2, delay: 0.2 }}
130
+ />
131
+ {/* X, second stroke */}
132
+ <m.path
133
+ d="M9 9l6 6"
134
+ stroke={color}
135
+ strokeWidth="1.5"
136
+ strokeLinecap="round"
137
+ initial={{ pathLength: 0 }}
138
+ animate={{ pathLength: 1 }}
139
+ transition={{ duration: 0.2, delay: 0.3 }}
140
+ />
141
+ </svg>
142
+ );
143
+ }
144
+
145
+ function InfoIcon({ size, color }: PartProps) {
146
+ return (
147
+ <m.svg
148
+ width={size}
149
+ height={size}
150
+ viewBox="0 0 24 24"
151
+ fill="none"
152
+ aria-hidden="true"
153
+ initial={{ y: 2, opacity: 0 }}
154
+ animate={{ y: 0, opacity: 1 }}
155
+ transition={{ duration: 0.3, ease: "easeOut" }}
156
+ >
157
+ {/* Circle */}
158
+ <circle cx="12" cy="12" r="10" stroke={color} strokeWidth="1.5" fill="none" />
159
+ {/* Dot */}
160
+ <m.circle
161
+ cx="12"
162
+ cy="8"
163
+ r="1"
164
+ fill={color}
165
+ initial={{ scale: 0 }}
166
+ animate={{ scale: 1 }}
167
+ transition={{ duration: 0.2, delay: 0.2, type: "spring" }}
168
+ />
169
+ {/* Line */}
170
+ <m.path
171
+ d="M12 12v4"
172
+ stroke={color}
173
+ strokeWidth="1.5"
174
+ strokeLinecap="round"
175
+ initial={{ pathLength: 0 }}
176
+ animate={{ pathLength: 1 }}
177
+ transition={{ duration: 0.2, delay: 0.3 }}
178
+ />
179
+ </m.svg>
180
+ );
181
+ }
182
+
183
+ const PARTS: Record<NotificationIconType, (props: PartProps) => React.JSX.Element> = {
184
+ success: SuccessIcon,
185
+ warning: WarningIcon,
186
+ error: ErrorIcon,
187
+ info: InfoIcon,
188
+ };
189
+
190
+ /**
191
+ * Notification status icon that animates on mount: the check draws itself,
192
+ * the warning shakes, the error pops in and the info icon rises. Decorative
193
+ * (`aria-hidden`); put the message text beside it.
194
+ */
195
+ export function AnimatedNotificationIcon({ type, size = 20, color, className }: AnimatedNotificationIconProps) {
196
+ const Part = PARTS[type];
197
+ return (
198
+ <span className={className} data-type={type}>
199
+ <Part size={size} color={color ?? TYPE_COLORS[type]} />
200
+ </span>
201
+ );
202
+ }
@@ -0,0 +1,74 @@
1
+ import { m, useSpring, useTransform } from "motion/react";
2
+ import { useEffect, useId } from "react";
3
+
4
+ export interface AnimatedProgressProps {
5
+ /** Progress from 0 to 100 (clamped). */
6
+ value: number;
7
+ /** Bar height in px. Default `4`. */
8
+ height?: number;
9
+ /** Class on the wrapping `div`. */
10
+ className?: string;
11
+ /** Run the moving gradient and shimmer. Default `true`. */
12
+ showGradient?: boolean;
13
+ /** Accessible name for the `progressbar`. */
14
+ label?: string;
15
+ }
16
+
17
+ /**
18
+ * Thin progress bar whose fill springs to `value`, with a moving gradient
19
+ * and shimmer (SVG `<animate>`). Fill: `--color-primary`; track:
20
+ * `--color-surface-secondary`. Renders `role="progressbar"`.
21
+ */
22
+ export function AnimatedProgress({ value, height = 4, className, showGradient = true, label }: AnimatedProgressProps) {
23
+ const id = useId();
24
+ const gradientId = `${id}-gradient`;
25
+ const shimmerId = `${id}-shimmer`;
26
+ const clamped = Math.min(Math.max(value, 0), 100);
27
+ const springValue = useSpring(0, { stiffness: 100, damping: 30 });
28
+ const width = useTransform(springValue, (v) => `${v}%`);
29
+
30
+ useEffect(() => {
31
+ springValue.set(clamped);
32
+ }, [clamped, springValue]);
33
+
34
+ return (
35
+ <div
36
+ className={className}
37
+ role="progressbar"
38
+ aria-label={label}
39
+ aria-valuemin={0}
40
+ aria-valuemax={100}
41
+ aria-valuenow={Math.round(clamped)}
42
+ style={{ height, overflow: "hidden", borderRadius: height / 2 }}
43
+ >
44
+ <svg width="100%" height={height} className="block" aria-hidden="true">
45
+ <defs>
46
+ {/* Moving gradient */}
47
+ <linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
48
+ <stop offset="0%" stopColor="var(--color-primary)" />
49
+ <stop offset="50%" stopColor="color-mix(in oklch, var(--color-primary) 80%, transparent)" />
50
+ <stop offset="100%" stopColor="var(--color-primary)" />
51
+ {showGradient ? <animate attributeName="x1" values="-100%;100%" dur="2s" repeatCount="indefinite" /> : null}
52
+ {showGradient ? <animate attributeName="x2" values="0%;200%" dur="2s" repeatCount="indefinite" /> : null}
53
+ </linearGradient>
54
+ {/* Shimmer overlay */}
55
+ <linearGradient id={shimmerId} x1="0%" y1="0%" x2="100%" y2="0%">
56
+ <stop offset="0%" stopColor="transparent" />
57
+ <stop offset="50%" stopColor="rgba(255,255,255,0.3)" />
58
+ <stop offset="100%" stopColor="transparent" />
59
+ <animate attributeName="x1" values="-50%;150%" dur="1.5s" repeatCount="indefinite" />
60
+ <animate attributeName="x2" values="0%;200%" dur="1.5s" repeatCount="indefinite" />
61
+ </linearGradient>
62
+ </defs>
63
+ {/* Track */}
64
+ <rect x="0" y="0" width="100%" height={height} rx={height / 2} fill="var(--color-surface-secondary)" />
65
+ {/* Fill */}
66
+ <m.rect x="0" y="0" height={height} rx={height / 2} fill={`url(#${gradientId})`} style={{ width }} />
67
+ {/* Shimmer */}
68
+ {showGradient && clamped > 0 ? (
69
+ <m.rect x="0" y="0" height={height} rx={height / 2} fill={`url(#${shimmerId})`} style={{ width }} />
70
+ ) : null}
71
+ </svg>
72
+ </div>
73
+ );
74
+ }
@@ -0,0 +1,98 @@
1
+ import { m } from "motion/react";
2
+ import { useId } from "react";
3
+
4
+ export type StatusDotPhase = "idle" | "listening" | "thinking" | "speaking" | "error";
5
+
6
+ export interface AnimatedStatusDotProps {
7
+ phase: StatusDotPhase;
8
+ /** Dot diameter in px. The svg is `size * 3` square to leave room for the ripples. Default `10`. */
9
+ size?: number;
10
+ className?: string;
11
+ }
12
+
13
+ const PHASE_COLORS: Record<StatusDotPhase, string> = {
14
+ idle: "var(--color-muted-foreground)",
15
+ listening: "var(--color-danger)",
16
+ thinking: "var(--color-warning)",
17
+ speaking: "var(--color-success)",
18
+ error: "var(--color-danger)",
19
+ };
20
+
21
+ const RIPPLE_DELAYS = [0, 0.5, 1];
22
+
23
+ /**
24
+ * Status dot for voice states: ripples while `listening`, a glow pulse while
25
+ * `listening` or `speaking`. Decorative (`aria-hidden`); show the phase as text too.
26
+ */
27
+ export function AnimatedStatusDot({ phase, size = 10, className }: AnimatedStatusDotProps) {
28
+ const filterId = `${useId()}-glow`;
29
+ const color = PHASE_COLORS[phase];
30
+ const isListening = phase === "listening";
31
+ const isActive = isListening || phase === "speaking";
32
+
33
+ return (
34
+ <svg
35
+ width={size * 3}
36
+ height={size * 3}
37
+ viewBox="0 0 30 30"
38
+ className={className}
39
+ overflow="visible"
40
+ aria-hidden="true"
41
+ data-phase={phase}
42
+ >
43
+ <defs>
44
+ <filter id={filterId} x="-50%" y="-50%" width="200%" height="200%">
45
+ <feGaussianBlur stdDeviation="2" result="coloredBlur" />
46
+ <feMerge>
47
+ <feMergeNode in="coloredBlur" />
48
+ <feMergeNode in="SourceGraphic" />
49
+ </feMerge>
50
+ </filter>
51
+ </defs>
52
+
53
+ {/* Ripples while listening */}
54
+ {isListening
55
+ ? RIPPLE_DELAYS.map((delay) => (
56
+ <m.circle
57
+ key={delay}
58
+ cx="15"
59
+ cy="15"
60
+ r="5"
61
+ fill="none"
62
+ stroke={color}
63
+ strokeWidth="1"
64
+ initial={{ r: 5, opacity: 0.6 }}
65
+ animate={{ r: 14, opacity: 0 }}
66
+ transition={{ duration: 1.5, repeat: Infinity, ease: "easeOut", delay }}
67
+ />
68
+ ))
69
+ : null}
70
+
71
+ {/* Glow ring while active */}
72
+ {isActive ? (
73
+ <m.circle
74
+ cx="15"
75
+ cy="15"
76
+ r="7"
77
+ fill={color}
78
+ opacity={0.3}
79
+ initial={{ scale: 1 }}
80
+ animate={{ scale: [1, 1.2, 1], opacity: [0.3, 0.15, 0.3] }}
81
+ transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
82
+ />
83
+ ) : null}
84
+
85
+ {/* Dot */}
86
+ <m.circle
87
+ cx="15"
88
+ cy="15"
89
+ r="5"
90
+ fill={color}
91
+ filter={isActive ? `url(#${filterId})` : undefined}
92
+ initial={{ scale: 0.8, opacity: 0 }}
93
+ animate={{ scale: 1, opacity: 1 }}
94
+ transition={{ duration: 0.2 }}
95
+ />
96
+ </svg>
97
+ );
98
+ }
@@ -0,0 +1,22 @@
1
+ // Motion-driven animated SVG icons. Entry: `@vvedantb/ui/icons/animated` (not re-exported from
2
+ // the root or `/client`, so apps that do not use them do not load them). They need the optional
3
+ // `motion` peer and animate through `m.*`, so wrap the app in `MotionProvider` (or its own
4
+ // `LazyMotion`); without it they render their resting frame. No stylesheet is needed.
5
+ export { AnimatedBellIcon } from "./animated-bell-icon";
6
+ export type { AnimatedBellIconProps } from "./animated-bell-icon";
7
+ export { AnimatedCounter } from "./animated-counter";
8
+ export type { AnimatedCounterProps } from "./animated-counter";
9
+ export { AnimatedKeyIcon } from "./animated-key-icon";
10
+ export type { AnimatedKeyIconProps } from "./animated-key-icon";
11
+ export { AnimatedNotificationIcon } from "./animated-notification-icon";
12
+ export type { AnimatedNotificationIconProps, NotificationIconType } from "./animated-notification-icon";
13
+ export { AnimatedProgress } from "./animated-progress";
14
+ export type { AnimatedProgressProps } from "./animated-progress";
15
+ export { AnimatedStatusDot } from "./animated-status-dot";
16
+ export type { AnimatedStatusDotProps, StatusDotPhase } from "./animated-status-dot";
17
+ export { MorphingMenuIcon } from "./morphing-menu-icon";
18
+ export type { MorphingMenuIconProps } from "./morphing-menu-icon";
19
+ export { MorphingMicIcon } from "./morphing-mic-icon";
20
+ export type { MorphingMicIconProps } from "./morphing-mic-icon";
21
+ export { PulsingRings } from "./pulsing-rings";
22
+ export type { PulsingRingsProps } from "./pulsing-rings";
@@ -0,0 +1,71 @@
1
+ import { m } from "motion/react";
2
+
3
+ export interface MorphingMenuIconProps {
4
+ /** `true` draws the X (close), `false` the three bars (menu). */
5
+ isOpen: boolean;
6
+ /** Width and height in px. Default `20`. */
7
+ size?: number;
8
+ className?: string;
9
+ }
10
+
11
+ const LINE = {
12
+ stroke: "currentColor",
13
+ strokeWidth: 2,
14
+ strokeLinecap: "round" as const,
15
+ strokeLinejoin: "round" as const,
16
+ };
17
+ const MORPH = { duration: 0.25, ease: "easeInOut" } as const;
18
+
19
+ /**
20
+ * Hamburger that morphs into an X: the outer bars cross and the middle bar
21
+ * fades out. Uses `currentColor`. Decorative (`aria-hidden`): label the button
22
+ * that holds it and set its `aria-expanded`.
23
+ */
24
+ export function MorphingMenuIcon({ isOpen, size = 20, className }: MorphingMenuIconProps) {
25
+ return (
26
+ <svg
27
+ width={size}
28
+ height={size}
29
+ viewBox="0 0 24 24"
30
+ fill="none"
31
+ className={className}
32
+ aria-hidden="true"
33
+ data-state={isOpen ? "open" : "closed"}
34
+ >
35
+ {/* Top bar: becomes the "\" of the X */}
36
+ <m.line
37
+ x1="4"
38
+ y1="6"
39
+ x2="20"
40
+ y2="6"
41
+ {...LINE}
42
+ initial={false}
43
+ animate={{ x1: isOpen ? 5 : 4, y1: isOpen ? 5 : 6, x2: isOpen ? 19 : 20, y2: isOpen ? 19 : 6 }}
44
+ transition={MORPH}
45
+ />
46
+ {/* Middle bar: fades out */}
47
+ <m.line
48
+ x1="4"
49
+ y1="12"
50
+ x2="20"
51
+ y2="12"
52
+ {...LINE}
53
+ initial={false}
54
+ animate={{ opacity: isOpen ? 0 : 1, scaleX: isOpen ? 0 : 1 }}
55
+ transition={{ duration: 0.15, ease: "easeInOut" }}
56
+ style={{ transformOrigin: "center" }}
57
+ />
58
+ {/* Bottom bar: becomes the "/" of the X */}
59
+ <m.line
60
+ x1="4"
61
+ y1="18"
62
+ x2="20"
63
+ y2="18"
64
+ {...LINE}
65
+ initial={false}
66
+ animate={{ x1: isOpen ? 5 : 4, y1: isOpen ? 19 : 18, x2: isOpen ? 19 : 20, y2: isOpen ? 5 : 18 }}
67
+ transition={MORPH}
68
+ />
69
+ </svg>
70
+ );
71
+ }
@@ -0,0 +1,52 @@
1
+ import { m } from "motion/react";
2
+ import { cn } from "../../utils/cn";
3
+
4
+ export interface MorphingMicIconProps {
5
+ /** `true` shows the stop square, `false` the microphone. */
6
+ isListening: boolean;
7
+ /** Width and height in px. Default `32`. */
8
+ size?: number;
9
+ strokeWidth?: number;
10
+ className?: string;
11
+ }
12
+
13
+ const ICON_TRANSITION = { type: "spring", duration: 0.3, bounce: 0 } as const;
14
+ const HIDDEN = { scale: 0.25, opacity: 0, filter: "blur(4px)" };
15
+ const VISIBLE = { scale: 1, opacity: 1, filter: "blur(0px)" };
16
+
17
+ /**
18
+ * Cross-fades a microphone and a stop square (scale, opacity and blur).
19
+ * Uses `currentColor`. Decorative (`aria-hidden`): label the button that holds it.
20
+ */
21
+ export function MorphingMicIcon({ isListening, size = 32, strokeWidth = 2, className }: MorphingMicIconProps) {
22
+ const svgProps = {
23
+ width: size,
24
+ height: size,
25
+ viewBox: "0 0 24 24",
26
+ fill: "none",
27
+ stroke: "currentColor",
28
+ strokeWidth,
29
+ strokeLinecap: "round" as const,
30
+ strokeLinejoin: "round" as const,
31
+ className: "absolute inset-0",
32
+ };
33
+
34
+ return (
35
+ <span
36
+ className={cn("relative inline-flex shrink-0", className)}
37
+ style={{ width: size, height: size }}
38
+ aria-hidden="true"
39
+ data-state={isListening ? "listening" : "idle"}
40
+ >
41
+ <m.svg {...svgProps} data-slot="mic" initial={false} animate={isListening ? HIDDEN : VISIBLE} transition={ICON_TRANSITION}>
42
+ <rect x="9" y="1" width="6" height="12" rx="3" />
43
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
44
+ <path d="M12 19v4" />
45
+ <path d="M8 23h8" />
46
+ </m.svg>
47
+ <m.svg {...svgProps} data-slot="stop" initial={false} animate={isListening ? VISIBLE : HIDDEN} transition={ICON_TRANSITION}>
48
+ <rect x="6" y="6" width="12" height="12" rx="2" />
49
+ </m.svg>
50
+ </span>
51
+ );
52
+ }
@@ -0,0 +1,50 @@
1
+ import { m } from "motion/react";
2
+ import { cn } from "../../utils/cn";
3
+
4
+ export interface PulsingRingsProps {
5
+ /** Size of the element the rings surround (for example the record button), in px. The svg is `size * 1.6`. */
6
+ size: number;
7
+ /** Number of rings. Default `3`. */
8
+ ringCount?: number;
9
+ /** Ring colour. Default `var(--color-danger)`. */
10
+ color?: string;
11
+ /** Render the rings. Default `true`; `false` renders nothing. */
12
+ active?: boolean;
13
+ className?: string;
14
+ }
15
+
16
+ /**
17
+ * Concentric rings that expand and fade with staggered timing, for a
18
+ * recording state. Absolutely centred: put it in a `relative` parent beside
19
+ * the button. Decorative and `pointer-events-none`.
20
+ */
21
+ export function PulsingRings({ size, ringCount = 3, color = "var(--color-danger)", active = true, className }: PulsingRingsProps) {
22
+ if (!active) return null;
23
+ const baseDelay = 1.2 / ringCount;
24
+
25
+ return (
26
+ <svg
27
+ width={size * 1.6}
28
+ height={size * 1.6}
29
+ viewBox="0 0 100 100"
30
+ className={cn("pointer-events-none absolute", className)}
31
+ style={{ left: "50%", top: "50%", transform: "translate(-50%, -50%)" }}
32
+ aria-hidden="true"
33
+ >
34
+ {Array.from({ length: ringCount }, (_, i) => (
35
+ <m.circle
36
+ key={i}
37
+ cx="50"
38
+ cy="50"
39
+ r="30"
40
+ fill="none"
41
+ stroke={color}
42
+ strokeWidth="2"
43
+ initial={{ r: 30, opacity: 0.6 }}
44
+ animate={{ r: 48, opacity: 0 }}
45
+ transition={{ duration: 1.2, repeat: Infinity, ease: "easeOut", delay: i * baseDelay }}
46
+ />
47
+ ))}
48
+ </svg>
49
+ );
50
+ }
package/src/index.ts CHANGED
@@ -330,6 +330,9 @@ export * from "../app/src/index";
330
330
  // Family app marks (Icon* components, deprecated AppMark and raw SVG strings for favicons).
331
331
  export * from "./brand";
332
332
 
333
+ // Third-party brand logos (`GitHubIcon`, `SlackIcon`, `ClaudeIcon`, ...). Also `@vvedantb/ui/logos`.
334
+ export * from "./logos";
335
+
333
336
  // Native tokens (JS values that match tokens.css).
334
337
  export { tokens } from "../tokens/src/native";
335
338
  export type { Tokens } from "../tokens/src/native";
@@ -0,0 +1,29 @@
1
+ import { LogoSvg, type LogoProps } from "./logo-svg";
2
+
3
+ /** Google Chrome mark (svgl `chrome.svg`). */
4
+ export function ChromeIcon(props: LogoProps) {
5
+ return (
6
+ <LogoSvg viewBox="0 0 190.5 190.5" preserveAspectRatio="xMidYMid" {...props}>
7
+ <path
8
+ fill="#fff"
9
+ d="M95.252 142.873c26.304 0 47.627-21.324 47.627-47.628s-21.323-47.628-47.627-47.628-47.627 21.324-47.627 47.628 21.323 47.628 47.627 47.628z"
10
+ />
11
+ <path
12
+ fill="#229342"
13
+ d="m54.005 119.07-41.24-71.43a95.227 95.227 0 0 0-.003 95.25 95.234 95.234 0 0 0 82.496 47.61l41.24-71.43v-.011a47.613 47.613 0 0 1-17.428 17.443 47.62 47.62 0 0 1-47.632.007 47.62 47.62 0 0 1-17.433-17.437z"
14
+ />
15
+ <path
16
+ fill="#fbc116"
17
+ d="m136.495 119.067-41.239 71.43a95.229 95.229 0 0 0 82.489-47.622A95.24 95.24 0 0 0 190.5 95.248a95.237 95.237 0 0 0-12.772-47.623H95.249l-.01.007a47.62 47.62 0 0 1 23.819 6.372 47.618 47.618 0 0 1 17.439 17.431 47.62 47.62 0 0 1-.001 47.633z"
18
+ />
19
+ <path
20
+ fill="#1a73e8"
21
+ d="M95.252 132.961c20.824 0 37.705-16.881 37.705-37.706S116.076 57.55 95.252 57.55 57.547 74.431 57.547 95.255s16.881 37.706 37.705 37.706z"
22
+ />
23
+ <path
24
+ fill="#e33b2e"
25
+ d="M95.252 47.628h82.479A95.237 95.237 0 0 0 142.87 12.76 95.23 95.23 0 0 0 95.245 0a95.222 95.222 0 0 0-47.623 12.767 95.23 95.23 0 0 0-34.856 34.872l41.24 71.43.011.006a47.62 47.62 0 0 1-.015-47.633 47.61 47.61 0 0 1 41.252-23.815z"
26
+ />
27
+ </LogoSvg>
28
+ );
29
+ }
@@ -0,0 +1,13 @@
1
+ import { LogoSvg, type LogoProps } from "./logo-svg";
2
+
3
+ /** Anthropic Claude mark. Monochrome: uses `currentColor`. */
4
+ export function ClaudeIcon(props: LogoProps) {
5
+ return (
6
+ <LogoSvg viewBox="0 0 24 24" {...props}>
7
+ <path
8
+ fill="currentColor"
9
+ d="m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z"
10
+ />
11
+ </LogoSvg>
12
+ );
13
+ }
@@ -0,0 +1,21 @@
1
+ import { LogoSvg, type LogoProps } from "./logo-svg";
2
+
3
+ /** Convex mark (svgl `convex.svg`). */
4
+ export function ConvexIcon(props: LogoProps) {
5
+ return (
6
+ <LogoSvg viewBox="28 28 128 132" fill="none" {...props}>
7
+ <path
8
+ fill="#F3B01C"
9
+ d="M108.092 130.021c18.166-2.018 35.293-11.698 44.723-27.854-4.466 39.961-48.162 65.218-83.83 49.711-3.286-1.425-6.115-3.796-8.056-6.844-8.016-12.586-10.65-28.601-6.865-43.135 10.817 18.668 32.81 30.111 54.028 28.122Z"
10
+ />
11
+ <path
12
+ fill="#8D2676"
13
+ d="M53.401 90.174c-7.364 17.017-7.682 36.94 1.345 53.336-31.77-23.902-31.423-75.052-.388-98.715 2.87-2.187 6.282-3.485 9.86-3.683 14.713-.776 29.662 4.91 40.146 15.507-21.3.212-42.046 13.857-50.963 33.555Z"
14
+ />
15
+ <path
16
+ fill="#EE342F"
17
+ d="M114.637 61.855C103.89 46.87 87.069 36.668 68.639 36.358c35.625-16.17 79.446 10.047 84.217 48.807.444 3.598-.139 7.267-1.734 10.512-6.656 13.518-18.998 24.002-33.42 27.882 10.567-19.599 9.263-43.544-3.065-61.704Z"
18
+ />
19
+ </LogoSvg>
20
+ );
21
+ }
@@ -0,0 +1,10 @@
1
+ import { LogoSvg, type LogoProps } from "./logo-svg";
2
+
3
+ /** Cursor mark (svgl `cursor_light.svg`). Monochrome: uses `currentColor`. */
4
+ export function CursorIcon(props: LogoProps) {
5
+ return (
6
+ <LogoSvg viewBox="0 0 466.73 532.09" fill="currentColor" {...props}>
7
+ <path d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z" />
8
+ </LogoSvg>
9
+ );
10
+ }
@@ -0,0 +1,14 @@
1
+ import { LogoSvg, type LogoProps } from "./logo-svg";
2
+
3
+ /** Dropbox mark. */
4
+ export function DropboxIcon(props: LogoProps) {
5
+ return (
6
+ <LogoSvg viewBox="0 0 24 24" {...props}>
7
+ <path d="M6 2L0 6L6 10L12 6L6 2Z" fill="#0061FF" />
8
+ <path d="M18 2L12 6L18 10L24 6L18 2Z" fill="#0061FF" />
9
+ <path d="M0 14L6 18L12 14L6 10L0 14Z" fill="#0061FF" />
10
+ <path d="M18 10L12 14L18 18L24 14L18 10Z" fill="#0061FF" />
11
+ <path d="M6 19.5L12 15.5L18 19.5L12 23.5L6 19.5Z" fill="#0061FF" />
12
+ </LogoSvg>
13
+ );
14
+ }