feat(design): animated design system foundation with animate-ui and motion

Install motion + animate-ui, overhaul design tokens with brand cyan accent,
and replace CSS keyframe animations in Dialog, Tabs, Switch, and Tooltip
with spring-physics and blur-fade transitions via animate-ui Radix primitives.
This commit is contained in:
SaelixCode
2026-03-20 22:25:29 -04:00
parent 4d1aef744b
commit 0cb5fae947
39 changed files with 5634 additions and 441 deletions
+146 -146
View File
@@ -162,7 +162,7 @@ export default function EditorLayout() {
}
};
// Notification WS push load history once on mount, then receive live updates
// Notification WS push - load history once on mount, then receive live updates
useEffect(() => {
fetchNotifications();
@@ -300,7 +300,7 @@ export default function EditorLayout() {
}
// Rate is derived from rawBytesRef which is never cleared on flush,
// so the delta is always accurate no stale-closure risk.
// so the delta is always accurate - no stale-closure risk.
const prevRaw = rawBytesRef.current[container.Id];
const rxRate = prevRaw ? Math.max(0, currentRx - prevRaw.lastRx) : 0;
const txRate = prevRaw ? Math.max(0, currentTx - prevRaw.lastTx) : 0;
@@ -308,7 +308,7 @@ export default function EditorLayout() {
const netIO = `${formatBytes(rxRate)}/s ↓ / ${formatBytes(txRate)}/s ↑`;
// Write into the buffer ref only zero re-render cost.
// Write into the buffer ref only - zero re-render cost.
pendingStatsRef.current[container.Id] = {
cpu: cpuPercent + '%',
ram: ramUsage,
@@ -770,7 +770,7 @@ export default function EditorLayout() {
<SelectItem key={node.id} value={node.id.toString()}>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full shrink-0 ${node.status === 'online' ? 'bg-green-500' :
node.status === 'offline' ? 'bg-red-500' : 'bg-gray-400'
node.status === 'offline' ? 'bg-red-500' : 'bg-gray-400'
}`} />
{node.name}
</div>
@@ -880,7 +880,7 @@ export default function EditorLayout() {
<div className="flex-1 flex flex-col overflow-hidden">
{/* Top Header Bar */}
<div className="h-16 flex items-center justify-between px-6 border-b border-border gap-4">
{/* Node Context Pill visible only when a remote node is active */}
{/* Node Context Pill - visible only when a remote node is active */}
<div className="flex-shrink-0">
{activeNode?.type === 'remote' ? (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-blue-500/10 border border-blue-500/20 text-blue-400 text-sm font-medium">
@@ -895,156 +895,156 @@ export default function EditorLayout() {
)}
</div>
<div className="flex items-center gap-4">
{/* Home Button */}
<Button
variant="outline"
size="sm"
className="rounded-lg"
onClick={() => {
setSelectedFile(null);
setContent('');
setOriginalContent('');
setEnvContent('');
setOriginalEnvContent('');
setEnvFiles([]);
setSelectedEnvFile('');
setEnvExists(false);
setContainers([]);
setIsEditing(false);
setActiveView('dashboard');
}}
title="Go to Home Dashboard"
>
<Home className="w-4 h-4 mr-2" />
Home
</Button>
{/* Console Toggle */}
<Button
variant={activeView === 'host-console' ? 'default' : 'outline'}
size="sm"
className="rounded-lg"
onClick={() => setActiveView(activeView === 'host-console' ? (selectedFile ? 'editor' : 'dashboard') : 'host-console')}
>
<Terminal className="w-4 h-4 mr-2" />
Console
</Button>
{/* Resources Toggle */}
<Button
variant="outline"
size="sm"
className="rounded-lg"
onClick={() => setActiveView('resources')}
title="System Resources"
>
<HardDrive className="w-4 h-4 mr-2" />
Resources
</Button>
{/* App Store Toggle */}
<Button
variant="outline"
size="sm"
className="rounded-lg"
onClick={() => setActiveView('templates')}
title="App Store"
>
<CloudDownload className="w-4 h-4 mr-2" />
App Store
</Button>
{/* Global Observability Toggle */}
<Button
variant="outline"
size="sm"
className="rounded-lg"
onClick={() => setActiveView('global-observability')}
title="Global Logs"
>
<Activity className="w-4 h-4 mr-2" />
Logs
</Button>
{/* Home Button */}
<Button
variant="outline"
size="sm"
className="rounded-lg"
onClick={() => {
setSelectedFile(null);
setContent('');
setOriginalContent('');
setEnvContent('');
setOriginalEnvContent('');
setEnvFiles([]);
setSelectedEnvFile('');
setEnvExists(false);
setContainers([]);
setIsEditing(false);
setActiveView('dashboard');
}}
title="Go to Home Dashboard"
>
<Home className="w-4 h-4 mr-2" />
Home
</Button>
{/* Console Toggle */}
<Button
variant={activeView === 'host-console' ? 'default' : 'outline'}
size="sm"
className="rounded-lg"
onClick={() => setActiveView(activeView === 'host-console' ? (selectedFile ? 'editor' : 'dashboard') : 'host-console')}
>
<Terminal className="w-4 h-4 mr-2" />
Console
</Button>
{/* Resources Toggle */}
<Button
variant="outline"
size="sm"
className="rounded-lg"
onClick={() => setActiveView('resources')}
title="System Resources"
>
<HardDrive className="w-4 h-4 mr-2" />
Resources
</Button>
{/* App Store Toggle */}
<Button
variant="outline"
size="sm"
className="rounded-lg"
onClick={() => setActiveView('templates')}
title="App Store"
>
<CloudDownload className="w-4 h-4 mr-2" />
App Store
</Button>
{/* Global Observability Toggle */}
<Button
variant="outline"
size="sm"
className="rounded-lg"
onClick={() => setActiveView('global-observability')}
title="Global Logs"
>
<Activity className="w-4 h-4 mr-2" />
Logs
</Button>
{/* Settings Modal Toggle */}
<Button
variant="outline"
size="sm"
className="rounded-lg"
onClick={() => setSettingsModalOpen(true)}
title="Notification Settings"
>
<Settings className="w-4 h-4 mr-2" />
Settings
</Button>
{/* Settings Modal Toggle */}
<Button
variant="outline"
size="sm"
className="rounded-lg"
onClick={() => setSettingsModalOpen(true)}
title="Notification Settings"
>
<Settings className="w-4 h-4 mr-2" />
Settings
</Button>
{/* Notifications Popover */}
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="rounded-lg relative" title="Notifications">
<Bell className="w-4 h-4" />
{notifications.filter(n => !n.is_read).length > 0 && (
<span className="absolute -top-1 -right-1 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="flex items-center justify-between p-4 border-b">
<h4 className="font-semibold">Notifications</h4>
<div className="flex gap-2">
{/* Notifications Popover */}
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="rounded-lg relative" title="Notifications">
<Bell className="w-4 h-4" />
{notifications.filter(n => !n.is_read).length > 0 && (
<Button variant="ghost" size="sm" onClick={markAllRead} className="h-auto p-0 text-xs">
Mark all as read
</Button>
<span className="absolute -top-1 -right-1 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
</span>
)}
{notifications.length > 0 && (
<Button variant="ghost" size="sm" onClick={clearAllNotifications} className="h-auto p-0 text-xs text-muted-foreground hover:text-destructive transition-colors">
<Trash2 className="w-3 h-3 mr-1" />
Clear all
</Button>
)}
</div>
</div>
<ScrollArea className="h-80">
{notifications.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground text-center">No notifications</div>
) : (
<div className="flex flex-col">
{notifications.map((notif: any) => (
<div key={notif.id} className={`p-4 border-b text-sm ${notif.is_read ? 'opacity-70' : 'bg-muted/50'} relative group`}>
<div className="flex items-center gap-2 mb-1 pr-6">
<Badge variant={notif.level === 'error' ? 'destructive' : notif.level === 'warning' ? 'secondary' : 'default'} className="text-[10px] uppercase">
{notif.level}
</Badge>
<span className="text-xs text-muted-foreground ml-auto">
{new Date(notif.timestamp).toLocaleString()}
</span>
</div>
<p className="font-medium pr-6">{notif.message}</p>
{/* Delete individual notification button */}
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
deleteNotification(notif.id);
}}
>
<X className="w-3 h-3" />
</Button>
</div>
))}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="flex items-center justify-between p-4 border-b">
<h4 className="font-semibold">Notifications</h4>
<div className="flex gap-2">
{notifications.filter(n => !n.is_read).length > 0 && (
<Button variant="ghost" size="sm" onClick={markAllRead} className="h-auto p-0 text-xs">
Mark all as read
</Button>
)}
{notifications.length > 0 && (
<Button variant="ghost" size="sm" onClick={clearAllNotifications} className="h-auto p-0 text-xs text-muted-foreground hover:text-destructive transition-colors">
<Trash2 className="w-3 h-3 mr-1" />
Clear all
</Button>
)}
</div>
)}
</ScrollArea>
</PopoverContent>
</Popover>
</div>
<ScrollArea className="h-80">
{notifications.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground text-center">No notifications</div>
) : (
<div className="flex flex-col">
{notifications.map((notif: any) => (
<div key={notif.id} className={`p-4 border-b text-sm ${notif.is_read ? 'opacity-70' : 'bg-muted/50'} relative group`}>
<div className="flex items-center gap-2 mb-1 pr-6">
<Badge variant={notif.level === 'error' ? 'destructive' : notif.level === 'warning' ? 'secondary' : 'default'} className="text-[10px] uppercase">
{notif.level}
</Badge>
<span className="text-xs text-muted-foreground ml-auto">
{new Date(notif.timestamp).toLocaleString()}
</span>
</div>
<p className="font-medium pr-6">{notif.message}</p>
{/* Delete individual notification button */}
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
deleteNotification(notif.id);
}}
>
<X className="w-3 h-3" />
</Button>
</div>
))}
</div>
)}
</ScrollArea>
</PopoverContent>
</Popover>
</div>{/* end right-side buttons */}
</div>
{/* Main Workspace */}
<div className="flex-1 overflow-y-auto p-6">
<div key={activeView} className="flex-1 overflow-y-auto p-6 animate-fade-up">
{activeView === 'templates' ? (
<AppStoreView onDeploySuccess={(stackName) => { refreshStacks(); loadFile(stackName); }} />
) : activeView === 'resources' ? (
+1 -1
View File
@@ -161,7 +161,7 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
<span>Host Console</span>
{activeNode && (
<span className="text-muted-foreground font-normal text-sm">
{activeNode.name}
- {activeNode.name}
</span>
)}
{stackName && (
+2 -2
View File
@@ -247,13 +247,13 @@ export function NodeManager() {
<SelectItem value="local">
<div className="flex items-center gap-2">
<Monitor className="w-4 h-4" />
Local Docker socket on this machine
Local - Docker socket on this machine
</div>
</SelectItem>
<SelectItem value="remote">
<div className="flex items-center gap-2">
<Globe className="w-4 h-4" />
Remote another Sencho instance
Remote - another Sencho instance
</div>
</SelectItem>
</SelectContent>
+1 -1
View File
@@ -191,7 +191,7 @@ export default function ResourcesView() {
<HardDrive className="w-6 h-6" />
<h1 className="text-2xl font-bold">Resources Hub</h1>
{activeNode?.type === 'remote' && (
<span className="text-sm font-normal text-muted-foreground"> {activeNode.name}</span>
<span className="text-sm font-normal text-muted-foreground">- {activeNode.name}</span>
)}
</div>
+28 -28
View File
@@ -83,7 +83,7 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
webhook: { type: 'webhook', url: '', enabled: false },
});
// Settings state all user-configurable keys (no auth keys)
// Settings state - all user-configurable keys (no auth keys)
const [settings, setSettings] = useState<PatchableSettings>({ ...DEFAULT_SETTINGS });
// Track server state to detect unsaved changes without causing re-renders
@@ -100,17 +100,17 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
// Unsaved changes indicators per section (compared against server ref)
const hasSystemChanges =
settings.host_cpu_limit !== serverSettingsRef.current.host_cpu_limit ||
settings.host_ram_limit !== serverSettingsRef.current.host_ram_limit ||
settings.host_disk_limit !== serverSettingsRef.current.host_disk_limit ||
settings.host_cpu_limit !== serverSettingsRef.current.host_cpu_limit ||
settings.host_ram_limit !== serverSettingsRef.current.host_ram_limit ||
settings.host_disk_limit !== serverSettingsRef.current.host_disk_limit ||
settings.docker_janitor_gb !== serverSettingsRef.current.docker_janitor_gb ||
settings.global_crash !== serverSettingsRef.current.global_crash;
settings.global_crash !== serverSettingsRef.current.global_crash;
const hasDeveloperChanges =
settings.developer_mode !== serverSettingsRef.current.developer_mode ||
settings.global_logs_refresh !== serverSettingsRef.current.global_logs_refresh ||
settings.developer_mode !== serverSettingsRef.current.developer_mode ||
settings.global_logs_refresh !== serverSettingsRef.current.global_logs_refresh ||
settings.metrics_retention_hours !== serverSettingsRef.current.metrics_retention_hours ||
settings.log_retention_days !== serverSettingsRef.current.log_retention_days;
settings.log_retention_days !== serverSettingsRef.current.log_retention_days;
useEffect(() => {
if (isOpen) {
@@ -140,7 +140,7 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
try {
// Fetch per-node settings from the active node (system limits etc.)
const nodeRes = await apiFetch('/settings');
// Always fetch developer/UI preferences from local these control
// Always fetch developer/UI preferences from local - these control
// this Sencho instance's behaviour and must never be proxied to remote
const localRes = isRemote ? await apiFetch('/settings', { localOnly: true }) : nodeRes;
@@ -151,17 +151,17 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
const safe: PatchableSettings = {
// Per-node: read from active node
host_cpu_limit: nodeData.host_cpu_limit ?? DEFAULT_SETTINGS.host_cpu_limit,
host_ram_limit: nodeData.host_ram_limit ?? DEFAULT_SETTINGS.host_ram_limit,
host_disk_limit: nodeData.host_disk_limit ?? DEFAULT_SETTINGS.host_disk_limit,
docker_janitor_gb: nodeData.docker_janitor_gb ?? DEFAULT_SETTINGS.docker_janitor_gb,
global_crash: (nodeData.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash,
template_registry_url: nodeData.template_registry_url ?? '',
host_cpu_limit: nodeData.host_cpu_limit ?? DEFAULT_SETTINGS.host_cpu_limit,
host_ram_limit: nodeData.host_ram_limit ?? DEFAULT_SETTINGS.host_ram_limit,
host_disk_limit: nodeData.host_disk_limit ?? DEFAULT_SETTINGS.host_disk_limit,
docker_janitor_gb: nodeData.docker_janitor_gb ?? DEFAULT_SETTINGS.docker_janitor_gb,
global_crash: (nodeData.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash,
template_registry_url: nodeData.template_registry_url ?? '',
// Local-only: always read from local node
global_logs_refresh: (localData.global_logs_refresh as '1' | '3' | '5' | '10') ?? DEFAULT_SETTINGS.global_logs_refresh,
developer_mode: (localData.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode,
global_logs_refresh: (localData.global_logs_refresh as '1' | '3' | '5' | '10') ?? DEFAULT_SETTINGS.global_logs_refresh,
developer_mode: (localData.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode,
metrics_retention_hours: localData.metrics_retention_hours ?? DEFAULT_SETTINGS.metrics_retention_hours,
log_retention_days: localData.log_retention_days ?? DEFAULT_SETTINGS.log_retention_days,
log_retention_days: localData.log_retention_days ?? DEFAULT_SETTINGS.log_retention_days,
};
setSettings(safe);
serverSettingsRef.current = { ...safe };
@@ -201,22 +201,22 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
const saveSystemSettings = async () => {
const ok = await patchSettings({
host_cpu_limit: settings.host_cpu_limit,
host_ram_limit: settings.host_ram_limit,
host_disk_limit: settings.host_disk_limit,
host_cpu_limit: settings.host_cpu_limit,
host_ram_limit: settings.host_ram_limit,
host_disk_limit: settings.host_disk_limit,
docker_janitor_gb: settings.docker_janitor_gb,
global_crash: settings.global_crash,
global_crash: settings.global_crash,
}, setIsSavingSystem);
if (ok) toast.success('System limits saved.');
};
const saveDeveloperSettings = async () => {
// Developer/UI preferences are local-only never proxy to remote node
// Developer/UI preferences are local-only - never proxy to remote node
const ok = await patchSettings({
developer_mode: settings.developer_mode,
global_logs_refresh: settings.global_logs_refresh,
developer_mode: settings.developer_mode,
global_logs_refresh: settings.global_logs_refresh,
metrics_retention_hours: settings.metrics_retention_hours,
log_retention_days: settings.log_retention_days,
log_retention_days: settings.log_retention_days,
}, setIsSavingDeveloper, true);
if (ok) toast.success('Developer settings saved.');
};
@@ -670,7 +670,7 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
</SelectContent>
</Select>
{settings.developer_mode === '1' && (
<p className="text-xs text-amber-500">SSE streaming is active polling rate is overridden.</p>
<p className="text-xs text-amber-500">SSE streaming is active - polling rate is overridden.</p>
)}
</div>
</div>
@@ -750,7 +750,7 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
<div className="space-y-1">
<Label className="text-base">Default Registry</Label>
<p className="text-xs text-muted-foreground">
LinuxServer.io <span className="font-mono">https://api.linuxserver.io/api/v1/images</span>
LinuxServer.io - <span className="font-mono">https://api.linuxserver.io/api/v1/images</span>
</p>
<p className="text-xs text-muted-foreground">Used when no custom registry is set.</p>
</div>
@@ -0,0 +1,96 @@
'use client';
import * as React from 'react';
import { motion, isMotionComponent, type HTMLMotionProps } from 'motion/react';
import { cn } from '@/lib/utils';
type AnyProps = Record<string, unknown>;
type DOMMotionProps<T extends HTMLElement = HTMLElement> = Omit<
HTMLMotionProps<keyof HTMLElementTagNameMap>,
'ref'
> & { ref?: React.Ref<T> };
type WithAsChild<Base extends object> =
| (Base & { asChild: true; children: React.ReactElement })
| (Base & { asChild?: false | undefined });
type SlotProps<T extends HTMLElement = HTMLElement> = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
children?: any;
} & DOMMotionProps<T>;
function mergeRefs<T>(
...refs: (React.Ref<T> | undefined)[]
): React.RefCallback<T> {
return (node) => {
refs.forEach((ref) => {
if (!ref) return;
if (typeof ref === 'function') {
ref(node);
} else {
(ref as React.RefObject<T | null>).current = node;
}
});
};
}
function mergeProps<T extends HTMLElement>(
childProps: AnyProps,
slotProps: DOMMotionProps<T>,
): AnyProps {
const merged: AnyProps = { ...childProps, ...slotProps };
if (childProps.className || slotProps.className) {
merged.className = cn(
childProps.className as string,
slotProps.className as string,
);
}
if (childProps.style || slotProps.style) {
merged.style = {
...(childProps.style as React.CSSProperties),
...(slotProps.style as React.CSSProperties),
};
}
return merged;
}
function Slot<T extends HTMLElement = HTMLElement>({
children,
ref,
...props
}: SlotProps<T>) {
const isAlreadyMotion =
typeof children.type === 'object' &&
children.type !== null &&
isMotionComponent(children.type);
const Base = React.useMemo(
() =>
isAlreadyMotion
? (children.type as React.ElementType)
: motion.create(children.type as React.ElementType),
[isAlreadyMotion, children.type],
);
if (!React.isValidElement(children)) return null;
const { ref: childRef, ...childProps } = children.props as AnyProps;
const mergedProps = mergeProps(childProps, props);
return (
<Base {...mergedProps} ref={mergeRefs(childRef as React.Ref<T>, ref)} />
);
}
export {
Slot,
type SlotProps,
type WithAsChild,
type DOMMotionProps,
type AnyProps,
};
@@ -0,0 +1,55 @@
'use client';
import * as React from 'react';
import {
motion,
type HTMLMotionProps,
type LegacyAnimationControls,
type TargetAndTransition,
type Transition,
} from 'motion/react';
import { useAutoHeight } from '@/hooks/use-auto-height';
import { Slot, type WithAsChild } from '@/components/animate-ui/primitives/animate/slot';
type AutoHeightProps = WithAsChild<
{
children: React.ReactNode;
deps?: React.DependencyList;
animate?: TargetAndTransition | LegacyAnimationControls;
transition?: Transition;
} & Omit<HTMLMotionProps<'div'>, 'animate'>
>;
function AutoHeight({
children,
deps = [],
transition = {
type: 'spring',
stiffness: 300,
damping: 30,
bounce: 0,
restDelta: 0.01,
},
style,
animate,
asChild = false,
...props
}: AutoHeightProps) {
const { ref, height } = useAutoHeight<HTMLDivElement>(deps);
const Comp = asChild ? Slot : motion.div;
return (
<Comp
style={{ overflow: 'hidden', ...style }}
animate={{ height, ...animate }}
transition={transition}
{...props}
>
<div ref={ref}>{children}</div>
</Comp>
);
}
export { AutoHeight, type AutoHeightProps };
@@ -0,0 +1,93 @@
'use client';
import * as React from 'react';
import { motion, type HTMLMotionProps } from 'motion/react';
import {
useIsInView,
type UseIsInViewOptions,
} from '@/hooks/use-is-in-view';
import { Slot, type WithAsChild } from '@/components/animate-ui/primitives/animate/slot';
type FadeProps = WithAsChild<
{
children?: React.ReactNode;
delay?: number;
initialOpacity?: number;
opacity?: number;
ref?: React.Ref<HTMLElement>;
} & UseIsInViewOptions &
HTMLMotionProps<'div'>
>;
function Fade({
ref,
transition = { type: 'spring', stiffness: 200, damping: 20 },
delay = 0,
inView = false,
inViewMargin = '0px',
inViewOnce = true,
initialOpacity = 0,
opacity = 1,
asChild = false,
...props
}: FadeProps) {
const { ref: localRef, isInView } = useIsInView(
ref as React.Ref<HTMLElement>,
{
inView,
inViewOnce,
inViewMargin,
},
);
const Component = asChild ? Slot : motion.div;
return (
<Component
ref={localRef as React.Ref<HTMLDivElement>}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
exit="hidden"
variants={{
hidden: { opacity: initialOpacity },
visible: { opacity },
}}
transition={{
...transition,
delay: (transition?.delay ?? 0) + delay / 1000,
}}
{...props}
/>
);
}
type FadeListProps = Omit<FadeProps, 'children'> & {
children: React.ReactElement | React.ReactElement[];
holdDelay?: number;
};
function Fades({
children,
delay = 0,
holdDelay = 0,
...props
}: FadeListProps) {
const array = React.Children.toArray(children) as React.ReactElement[];
return (
<>
{array.map((child, index) => (
<Fade
key={child.key ?? index}
delay={delay + index * holdDelay}
{...props}
>
{child}
</Fade>
))}
</>
);
}
export { Fade, Fades, type FadeProps, type FadeListProps };
@@ -0,0 +1,640 @@
'use client';
import * as React from 'react';
import { AnimatePresence, motion, type Transition } from 'motion/react';
import { cn } from '@/lib/utils';
type HighlightMode = 'children' | 'parent';
type Bounds = {
top: number;
left: number;
width: number;
height: number;
};
const DEFAULT_BOUNDS_OFFSET: Bounds = {
top: 0,
left: 0,
width: 0,
height: 0,
};
type HighlightContextType<T extends string> = {
as?: keyof HTMLElementTagNameMap;
mode: HighlightMode;
activeValue: T | null;
setActiveValue: (value: T | null) => void;
setBounds: (bounds: DOMRect) => void;
clearBounds: () => void;
id: string;
hover: boolean;
click: boolean;
className?: string;
style?: React.CSSProperties;
activeClassName?: string;
setActiveClassName: (className: string) => void;
transition?: Transition;
disabled?: boolean;
enabled?: boolean;
exitDelay?: number;
forceUpdateBounds?: boolean;
};
const HighlightContext = React.createContext<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
HighlightContextType<any> | undefined
>(undefined);
function useHighlight<T extends string>(): HighlightContextType<T> {
const context = React.useContext(HighlightContext);
if (!context) {
throw new Error('useHighlight must be used within a HighlightProvider');
}
return context as unknown as HighlightContextType<T>;
}
type BaseHighlightProps<T extends React.ElementType = 'div'> = {
as?: T;
ref?: React.Ref<HTMLDivElement>;
mode?: HighlightMode;
value?: string | null;
defaultValue?: string | null;
onValueChange?: (value: string | null) => void;
className?: string;
style?: React.CSSProperties;
transition?: Transition;
hover?: boolean;
click?: boolean;
disabled?: boolean;
enabled?: boolean;
exitDelay?: number;
};
type ParentModeHighlightProps = {
boundsOffset?: Partial<Bounds>;
containerClassName?: string;
forceUpdateBounds?: boolean;
};
type ControlledParentModeHighlightProps<T extends React.ElementType = 'div'> =
BaseHighlightProps<T> &
ParentModeHighlightProps & {
mode: 'parent';
controlledItems: true;
children: React.ReactNode;
};
type ControlledChildrenModeHighlightProps<T extends React.ElementType = 'div'> =
BaseHighlightProps<T> & {
mode?: 'children' | undefined;
controlledItems: true;
children: React.ReactNode;
};
type UncontrolledParentModeHighlightProps<T extends React.ElementType = 'div'> =
BaseHighlightProps<T> &
ParentModeHighlightProps & {
mode: 'parent';
controlledItems?: false;
itemsClassName?: string;
children: React.ReactElement | React.ReactElement[];
};
type UncontrolledChildrenModeHighlightProps<
T extends React.ElementType = 'div',
> = BaseHighlightProps<T> & {
mode?: 'children';
controlledItems?: false;
itemsClassName?: string;
children: React.ReactElement | React.ReactElement[];
};
type HighlightProps<T extends React.ElementType = 'div'> =
| ControlledParentModeHighlightProps<T>
| ControlledChildrenModeHighlightProps<T>
| UncontrolledParentModeHighlightProps<T>
| UncontrolledChildrenModeHighlightProps<T>;
function Highlight<T extends React.ElementType = 'div'>({
ref,
...props
}: HighlightProps<T>) {
const {
as: Component = 'div',
children,
value,
defaultValue,
onValueChange,
className,
style,
transition = { type: 'spring', stiffness: 350, damping: 35 },
hover = false,
click = true,
enabled = true,
controlledItems,
disabled = false,
exitDelay = 200,
mode = 'children',
} = props;
const localRef = React.useRef<HTMLDivElement>(null);
React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement);
const propsBoundsOffset = (props as ParentModeHighlightProps)?.boundsOffset;
const boundsOffset = propsBoundsOffset ?? DEFAULT_BOUNDS_OFFSET;
const boundsOffsetTop = boundsOffset.top ?? 0;
const boundsOffsetLeft = boundsOffset.left ?? 0;
const boundsOffsetWidth = boundsOffset.width ?? 0;
const boundsOffsetHeight = boundsOffset.height ?? 0;
const boundsOffsetRef = React.useRef({
top: boundsOffsetTop,
left: boundsOffsetLeft,
width: boundsOffsetWidth,
height: boundsOffsetHeight,
});
React.useEffect(() => {
boundsOffsetRef.current = {
top: boundsOffsetTop,
left: boundsOffsetLeft,
width: boundsOffsetWidth,
height: boundsOffsetHeight,
};
}, [
boundsOffsetTop,
boundsOffsetLeft,
boundsOffsetWidth,
boundsOffsetHeight,
]);
const [activeValue, setActiveValue] = React.useState<string | null>(
value ?? defaultValue ?? null,
);
const [boundsState, setBoundsState] = React.useState<Bounds | null>(null);
const [activeClassNameState, setActiveClassNameState] =
React.useState<string>('');
const safeSetActiveValue = (id: string | null) => {
setActiveValue((prev) => {
if (prev !== id) {
onValueChange?.(id);
return id;
}
return prev;
});
};
const safeSetBoundsRef = React.useRef<
((bounds: DOMRect) => void) | undefined
>(undefined);
React.useEffect(() => {
safeSetBoundsRef.current = (bounds: DOMRect) => {
if (!localRef.current) return;
const containerRect = localRef.current.getBoundingClientRect();
const offset = boundsOffsetRef.current;
const newBounds: Bounds = {
top: bounds.top - containerRect.top + offset.top,
left: bounds.left - containerRect.left + offset.left,
width: bounds.width + offset.width,
height: bounds.height + offset.height,
};
setBoundsState((prev) => {
if (
prev &&
prev.top === newBounds.top &&
prev.left === newBounds.left &&
prev.width === newBounds.width &&
prev.height === newBounds.height
) {
return prev;
}
return newBounds;
});
};
});
const safeSetBounds = (bounds: DOMRect) => {
safeSetBoundsRef.current?.(bounds);
};
const clearBounds = React.useCallback(() => {
setBoundsState((prev) => (prev === null ? prev : null));
}, []);
React.useEffect(() => {
if (value !== undefined) setActiveValue(value);
else if (defaultValue !== undefined) setActiveValue(defaultValue);
}, [value, defaultValue]);
const id = React.useId();
React.useEffect(() => {
if (mode !== 'parent') return;
const container = localRef.current;
if (!container) return;
const onScroll = () => {
if (!activeValue) return;
const activeEl = container.querySelector<HTMLElement>(
`[data-value="${activeValue}"][data-highlight="true"]`,
);
if (activeEl)
safeSetBoundsRef.current?.(activeEl.getBoundingClientRect());
};
container.addEventListener('scroll', onScroll, { passive: true });
return () => container.removeEventListener('scroll', onScroll);
}, [mode, activeValue]);
const render = (children: React.ReactNode) => {
if (mode === 'parent') {
return (
<Component
ref={localRef}
data-slot="motion-highlight-container"
style={{ position: 'relative', zIndex: 1 }}
className={(props as ParentModeHighlightProps)?.containerClassName}
>
<AnimatePresence initial={false} mode="wait">
{boundsState && (
<motion.div
data-slot="motion-highlight"
animate={{
top: boundsState.top,
left: boundsState.left,
width: boundsState.width,
height: boundsState.height,
opacity: 1,
}}
initial={{
top: boundsState.top,
left: boundsState.left,
width: boundsState.width,
height: boundsState.height,
opacity: 0,
}}
exit={{
opacity: 0,
transition: {
...transition,
delay: (transition?.delay ?? 0) + (exitDelay ?? 0) / 1000,
},
}}
transition={transition}
style={{ position: 'absolute', zIndex: 0, ...style }}
className={cn(className, activeClassNameState)}
/>
)}
</AnimatePresence>
{children}
</Component>
);
}
return children;
};
return (
<HighlightContext.Provider
value={{
mode,
activeValue,
setActiveValue: safeSetActiveValue,
id,
hover,
click,
className,
style,
transition,
disabled,
enabled,
exitDelay,
setBounds: safeSetBounds,
clearBounds,
activeClassName: activeClassNameState,
setActiveClassName: setActiveClassNameState,
forceUpdateBounds: (props as ParentModeHighlightProps)
?.forceUpdateBounds,
}}
>
{enabled
? controlledItems
? render(children)
: render(
React.Children.map(children, (child, index) => (
<HighlightItem key={index} className={props?.itemsClassName}>
{child}
</HighlightItem>
)),
)
: children}
</HighlightContext.Provider>
);
}
function getNonOverridingDataAttributes(
element: React.ReactElement,
dataAttributes: Record<string, unknown>,
): Record<string, unknown> {
return Object.keys(dataAttributes).reduce<Record<string, unknown>>(
(acc, key) => {
if ((element.props as Record<string, unknown>)[key] === undefined) {
acc[key] = dataAttributes[key];
}
return acc;
},
{},
);
}
type ExtendedChildProps = React.ComponentProps<'div'> & {
id?: string;
ref?: React.Ref<HTMLElement>;
'data-active'?: string;
'data-value'?: string;
'data-disabled'?: boolean;
'data-highlight'?: boolean;
'data-slot'?: string;
};
type HighlightItemProps<T extends React.ElementType = 'div'> =
React.ComponentProps<T> & {
as?: T;
children: React.ReactElement;
id?: string;
value?: string;
className?: string;
style?: React.CSSProperties;
transition?: Transition;
activeClassName?: string;
disabled?: boolean;
exitDelay?: number;
asChild?: boolean;
forceUpdateBounds?: boolean;
};
function HighlightItem<T extends React.ElementType>({
ref,
as,
children,
id,
value,
className,
style,
transition,
disabled = false,
activeClassName,
exitDelay,
asChild = false,
forceUpdateBounds,
...props
}: HighlightItemProps<T>) {
const itemId = React.useId();
const {
activeValue,
setActiveValue,
mode,
setBounds,
clearBounds,
hover,
click,
enabled,
className: contextClassName,
style: contextStyle,
transition: contextTransition,
id: contextId,
disabled: contextDisabled,
exitDelay: contextExitDelay,
forceUpdateBounds: contextForceUpdateBounds,
setActiveClassName,
} = useHighlight();
const Component = as ?? 'div';
const element = children as React.ReactElement<ExtendedChildProps>;
const childValue =
id ?? value ?? element.props?.['data-value'] ?? element.props?.id ?? itemId;
const isActive = activeValue === childValue;
const isDisabled = disabled === undefined ? contextDisabled : disabled;
const itemTransition = transition ?? contextTransition;
const localRef = React.useRef<HTMLDivElement>(null);
React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement);
const refCallback = React.useCallback((node: HTMLElement | null) => {
localRef.current = node as HTMLDivElement;
}, []);
React.useEffect(() => {
if (mode !== 'parent') return;
let rafId: number;
let previousBounds: Bounds | null = null;
const shouldUpdateBounds =
forceUpdateBounds === true ||
(contextForceUpdateBounds && forceUpdateBounds !== false);
const updateBounds = () => {
if (!localRef.current) return;
const bounds = localRef.current.getBoundingClientRect();
if (shouldUpdateBounds) {
if (
previousBounds &&
previousBounds.top === bounds.top &&
previousBounds.left === bounds.left &&
previousBounds.width === bounds.width &&
previousBounds.height === bounds.height
) {
rafId = requestAnimationFrame(updateBounds);
return;
}
previousBounds = bounds;
rafId = requestAnimationFrame(updateBounds);
}
setBounds(bounds);
};
if (isActive) {
updateBounds();
setActiveClassName(activeClassName ?? '');
} else if (!activeValue) clearBounds();
if (shouldUpdateBounds) return () => cancelAnimationFrame(rafId);
}, [
mode,
isActive,
activeValue,
setBounds,
clearBounds,
activeClassName,
setActiveClassName,
forceUpdateBounds,
contextForceUpdateBounds,
]);
if (!React.isValidElement(children)) return children;
const dataAttributes = {
'data-active': isActive ? 'true' : 'false',
'aria-selected': isActive,
'data-disabled': isDisabled,
'data-value': childValue,
'data-highlight': true,
};
const commonHandlers = hover
? {
onMouseEnter: (e: React.MouseEvent<HTMLDivElement>) => {
setActiveValue(childValue);
element.props.onMouseEnter?.(e);
},
onMouseLeave: (e: React.MouseEvent<HTMLDivElement>) => {
setActiveValue(null);
element.props.onMouseLeave?.(e);
},
}
: click
? {
onClick: (e: React.MouseEvent<HTMLDivElement>) => {
setActiveValue(childValue);
element.props.onClick?.(e);
},
}
: {};
if (asChild) {
if (mode === 'children') {
return React.cloneElement(
element,
{
key: childValue,
ref: refCallback,
className: cn('relative', element.props.className),
...getNonOverridingDataAttributes(element, {
...dataAttributes,
'data-slot': 'motion-highlight-item-container',
}),
...commonHandlers,
...props,
},
<>
<AnimatePresence initial={false} mode="wait">
{isActive && !isDisabled && (
<motion.div
layoutId={`transition-background-${contextId}`}
data-slot="motion-highlight"
style={{
position: 'absolute',
zIndex: 0,
...contextStyle,
...style,
}}
className={cn(contextClassName, activeClassName)}
transition={itemTransition}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{
opacity: 0,
transition: {
...itemTransition,
delay:
(itemTransition?.delay ?? 0) +
(exitDelay ?? contextExitDelay ?? 0) / 1000,
},
}}
{...dataAttributes}
/>
)}
</AnimatePresence>
<Component
data-slot="motion-highlight-item"
style={{ position: 'relative', zIndex: 1 }}
className={className}
{...dataAttributes}
>
{children}
</Component>
</>,
);
}
return React.cloneElement(element, {
ref: refCallback,
...getNonOverridingDataAttributes(element, {
...dataAttributes,
'data-slot': 'motion-highlight-item',
}),
...commonHandlers,
});
}
return enabled ? (
<Component
key={childValue}
ref={localRef}
data-slot="motion-highlight-item-container"
className={cn(mode === 'children' && 'relative', className)}
{...dataAttributes}
{...props}
{...commonHandlers}
>
{mode === 'children' && (
<AnimatePresence initial={false} mode="wait">
{isActive && !isDisabled && (
<motion.div
layoutId={`transition-background-${contextId}`}
data-slot="motion-highlight"
style={{
position: 'absolute',
zIndex: 0,
...contextStyle,
...style,
}}
className={cn(contextClassName, activeClassName)}
transition={itemTransition}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{
opacity: 0,
transition: {
...itemTransition,
delay:
(itemTransition?.delay ?? 0) +
(exitDelay ?? contextExitDelay ?? 0) / 1000,
},
}}
{...dataAttributes}
/>
)}
</AnimatePresence>
)}
{React.cloneElement(element, {
style: { position: 'relative', zIndex: 1 },
className: element.props.className,
...getNonOverridingDataAttributes(element, {
...dataAttributes,
'data-slot': 'motion-highlight-item',
}),
})}
</Component>
) : (
children
);
}
export {
Highlight,
HighlightItem,
useHighlight,
type HighlightProps,
type HighlightItemProps,
};
@@ -0,0 +1,207 @@
'use client';
import * as React from 'react';
import { Dialog as DialogPrimitive } from 'radix-ui';
import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
import { useControlledState } from '@/hooks/use-controlled-state';
import { getStrictContext } from '@/lib/get-strict-context';
type DialogContextType = {
isOpen: boolean;
setIsOpen: DialogProps['onOpenChange'];
};
const [DialogProvider, useDialog] =
getStrictContext<DialogContextType>('DialogContext');
type DialogProps = React.ComponentProps<typeof DialogPrimitive.Root>;
function Dialog(props: DialogProps) {
const [isOpen, setIsOpen] = useControlledState({
value: props?.open,
defaultValue: props?.defaultOpen,
onChange: props?.onOpenChange,
});
return (
<DialogProvider value={{ isOpen, setIsOpen }}>
<DialogPrimitive.Root
data-slot="dialog"
{...props}
onOpenChange={setIsOpen}
/>
</DialogProvider>
);
}
type DialogTriggerProps = React.ComponentProps<typeof DialogPrimitive.Trigger>;
function DialogTrigger(props: DialogTriggerProps) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
type DialogPortalProps = Omit<
React.ComponentProps<typeof DialogPrimitive.Portal>,
'forceMount'
>;
function DialogPortal(props: DialogPortalProps) {
const { isOpen } = useDialog();
return (
<AnimatePresence>
{isOpen && (
<DialogPrimitive.Portal
data-slot="dialog-portal"
forceMount
{...props}
/>
)}
</AnimatePresence>
);
}
type DialogOverlayProps = Omit<
React.ComponentProps<typeof DialogPrimitive.Overlay>,
'forceMount' | 'asChild'
> &
HTMLMotionProps<'div'>;
function DialogOverlay({
transition = { duration: 0.2, ease: 'easeInOut' },
...props
}: DialogOverlayProps) {
return (
<DialogPrimitive.Overlay data-slot="dialog-overlay" asChild forceMount>
<motion.div
key="dialog-overlay"
initial={{ opacity: 0, filter: 'blur(4px)' }}
animate={{ opacity: 1, filter: 'blur(0px)' }}
exit={{ opacity: 0, filter: 'blur(4px)' }}
transition={transition}
{...props}
/>
</DialogPrimitive.Overlay>
);
}
type DialogFlipDirection = 'top' | 'bottom' | 'left' | 'right';
type DialogContentProps = Omit<
React.ComponentProps<typeof DialogPrimitive.Content>,
'forceMount' | 'asChild'
> &
HTMLMotionProps<'div'> & {
from?: DialogFlipDirection;
};
function DialogContent({
from = 'top',
onOpenAutoFocus,
onCloseAutoFocus,
onEscapeKeyDown,
onPointerDownOutside,
onInteractOutside,
transition = { type: 'spring', stiffness: 150, damping: 25 },
...props
}: DialogContentProps) {
const initialRotation =
from === 'bottom' || from === 'left' ? '20deg' : '-20deg';
const isVertical = from === 'top' || from === 'bottom';
const rotateAxis = isVertical ? 'rotateX' : 'rotateY';
return (
<DialogPrimitive.Content
asChild
forceMount
onOpenAutoFocus={onOpenAutoFocus}
onCloseAutoFocus={onCloseAutoFocus}
onEscapeKeyDown={onEscapeKeyDown}
onPointerDownOutside={onPointerDownOutside}
onInteractOutside={onInteractOutside}
>
<motion.div
key="dialog-content"
data-slot="dialog-content"
initial={{
opacity: 0,
filter: 'blur(4px)',
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
}}
animate={{
opacity: 1,
filter: 'blur(0px)',
transform: `perspective(500px) ${rotateAxis}(0deg) scale(1)`,
}}
exit={{
opacity: 0,
filter: 'blur(4px)',
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
}}
transition={transition}
{...props}
/>
</DialogPrimitive.Content>
);
}
type DialogCloseProps = React.ComponentProps<typeof DialogPrimitive.Close>;
function DialogClose(props: DialogCloseProps) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
type DialogHeaderProps = React.ComponentProps<'div'>;
function DialogHeader(props: DialogHeaderProps) {
return <div data-slot="dialog-header" {...props} />;
}
type DialogFooterProps = React.ComponentProps<'div'>;
function DialogFooter(props: DialogFooterProps) {
return <div data-slot="dialog-footer" {...props} />;
}
type DialogTitleProps = React.ComponentProps<typeof DialogPrimitive.Title>;
function DialogTitle(props: DialogTitleProps) {
return <DialogPrimitive.Title data-slot="dialog-title" {...props} />;
}
type DialogDescriptionProps = React.ComponentProps<
typeof DialogPrimitive.Description
>;
function DialogDescription(props: DialogDescriptionProps) {
return (
<DialogPrimitive.Description data-slot="dialog-description" {...props} />
);
}
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
useDialog,
type DialogProps,
type DialogTriggerProps,
type DialogPortalProps,
type DialogCloseProps,
type DialogOverlayProps,
type DialogContentProps,
type DialogHeaderProps,
type DialogFooterProps,
type DialogTitleProps,
type DialogDescriptionProps,
type DialogContextType,
type DialogFlipDirection,
};
@@ -0,0 +1,563 @@
'use client';
import * as React from 'react';
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';
import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
import {
Highlight,
HighlightItem,
type HighlightItemProps,
type HighlightProps,
} from '@/components/animate-ui/primitives/effects/highlight';
import { getStrictContext } from '@/lib/get-strict-context';
import { useControlledState } from '@/hooks/use-controlled-state';
import { useDataState } from '@/hooks/use-data-state';
type DropdownMenuContextType = {
isOpen: boolean;
setIsOpen: (o: boolean) => void;
highlightedValue: string | null;
setHighlightedValue: (value: string | null) => void;
};
type DropdownMenuSubContextType = {
isOpen: boolean;
setIsOpen: (o: boolean) => void;
};
const [DropdownMenuProvider, useDropdownMenu] =
getStrictContext<DropdownMenuContextType>('DropdownMenuContext');
const [DropdownMenuSubProvider, useDropdownMenuSub] =
getStrictContext<DropdownMenuSubContextType>('DropdownMenuSubContext');
type DropdownMenuProps = React.ComponentProps<
typeof DropdownMenuPrimitive.Root
>;
function DropdownMenu(props: DropdownMenuProps) {
const [isOpen, setIsOpen] = useControlledState({
value: props?.open,
defaultValue: props?.defaultOpen,
onChange: props?.onOpenChange,
});
const [highlightedValue, setHighlightedValue] = React.useState<string | null>(
null,
);
return (
<DropdownMenuProvider
value={{ isOpen, setIsOpen, highlightedValue, setHighlightedValue }}
>
<DropdownMenuPrimitive.Root
data-slot="dropdown-menu"
{...props}
onOpenChange={setIsOpen}
/>
</DropdownMenuProvider>
);
}
type DropdownMenuTriggerProps = React.ComponentProps<
typeof DropdownMenuPrimitive.Trigger
>;
function DropdownMenuTrigger(props: DropdownMenuTriggerProps) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
type DropdownMenuPortalProps = React.ComponentProps<
typeof DropdownMenuPrimitive.Portal
>;
function DropdownMenuPortal(props: DropdownMenuPortalProps) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
type DropdownMenuGroupProps = React.ComponentProps<
typeof DropdownMenuPrimitive.Group
>;
function DropdownMenuGroup(props: DropdownMenuGroupProps) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
type DropdownMenuSubProps = React.ComponentProps<
typeof DropdownMenuPrimitive.Sub
>;
function DropdownMenuSub(props: DropdownMenuSubProps) {
const [isOpen, setIsOpen] = useControlledState({
value: props?.open,
defaultValue: props?.defaultOpen,
onChange: props?.onOpenChange,
});
return (
<DropdownMenuSubProvider value={{ isOpen, setIsOpen }}>
<DropdownMenuPrimitive.Sub
data-slot="dropdown-menu-sub"
{...props}
onOpenChange={setIsOpen}
/>
</DropdownMenuSubProvider>
);
}
type DropdownMenuRadioGroupProps = React.ComponentProps<
typeof DropdownMenuPrimitive.RadioGroup
>;
function DropdownMenuRadioGroup(props: DropdownMenuRadioGroupProps) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
type DropdownMenuSubTriggerProps = Omit<
React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger>,
'asChild'
> &
HTMLMotionProps<'div'>;
function DropdownMenuSubTrigger({
disabled,
textValue,
...props
}: DropdownMenuSubTriggerProps) {
const { setHighlightedValue } = useDropdownMenu();
const [, highlightedRef] = useDataState<HTMLDivElement>(
'highlighted',
undefined,
(value) => {
if (value === true) {
const el = highlightedRef.current;
const v = el?.dataset.value || el?.id || null;
if (v) setHighlightedValue(v);
}
},
);
return (
<DropdownMenuPrimitive.SubTrigger
ref={highlightedRef}
disabled={disabled}
textValue={textValue}
asChild
>
<motion.div
data-slot="dropdown-menu-sub-trigger"
data-disabled={disabled}
{...props}
/>
</DropdownMenuPrimitive.SubTrigger>
);
}
type DropdownMenuSubContentProps = Omit<
React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>,
'forceMount' | 'asChild'
> &
Omit<
React.ComponentProps<typeof DropdownMenuPrimitive.Portal>,
'forceMount'
> &
HTMLMotionProps<'div'>;
function DropdownMenuSubContent({
loop,
onEscapeKeyDown,
onPointerDownOutside,
onFocusOutside,
onInteractOutside,
sideOffset,
alignOffset,
avoidCollisions,
collisionBoundary,
collisionPadding,
arrowPadding,
sticky,
hideWhenDetached,
transition = { duration: 0.2 },
style,
container,
...props
}: DropdownMenuSubContentProps) {
const { isOpen } = useDropdownMenuSub();
return (
<AnimatePresence>
{isOpen && (
<DropdownMenuPortal forceMount container={container}>
<DropdownMenuPrimitive.SubContent
asChild
forceMount
loop={loop}
onEscapeKeyDown={onEscapeKeyDown}
onPointerDownOutside={onPointerDownOutside}
onFocusOutside={onFocusOutside}
onInteractOutside={onInteractOutside}
sideOffset={sideOffset}
alignOffset={alignOffset}
avoidCollisions={avoidCollisions}
collisionBoundary={collisionBoundary}
collisionPadding={collisionPadding}
arrowPadding={arrowPadding}
sticky={sticky}
hideWhenDetached={hideWhenDetached}
>
<motion.div
key="dropdown-menu-sub-content"
data-slot="dropdown-menu-sub-content"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={transition}
style={{ willChange: 'opacity, transform', ...style }}
{...props}
/>
</DropdownMenuPrimitive.SubContent>
</DropdownMenuPortal>
)}
</AnimatePresence>
);
}
type DropdownMenuHighlightProps = Omit<
HighlightProps,
'controlledItems' | 'enabled' | 'hover'
> & {
animateOnHover?: boolean;
};
function DropdownMenuHighlight({
transition = { type: 'spring', stiffness: 350, damping: 35 },
...props
}: DropdownMenuHighlightProps) {
const { highlightedValue } = useDropdownMenu();
return (
<Highlight
data-slot="dropdown-menu-highlight"
click={false}
controlledItems
transition={transition}
value={highlightedValue}
{...props}
/>
);
}
type DropdownMenuContentProps = Omit<
React.ComponentProps<typeof DropdownMenuPrimitive.Content>,
'forceMount' | 'asChild'
> &
Omit<
React.ComponentProps<typeof DropdownMenuPrimitive.Portal>,
'forceMount'
> &
HTMLMotionProps<'div'>;
function DropdownMenuContent({
loop,
onCloseAutoFocus,
onEscapeKeyDown,
onPointerDownOutside,
onFocusOutside,
onInteractOutside,
side,
sideOffset,
align,
alignOffset,
avoidCollisions,
collisionBoundary,
collisionPadding,
arrowPadding,
sticky,
hideWhenDetached,
transition = { duration: 0.2 },
style,
container,
...props
}: DropdownMenuContentProps) {
const { isOpen } = useDropdownMenu();
return (
<AnimatePresence>
{isOpen && (
<DropdownMenuPortal forceMount container={container}>
<DropdownMenuPrimitive.Content
asChild
loop={loop}
onCloseAutoFocus={onCloseAutoFocus}
onEscapeKeyDown={onEscapeKeyDown}
onPointerDownOutside={onPointerDownOutside}
onFocusOutside={onFocusOutside}
onInteractOutside={onInteractOutside}
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
avoidCollisions={avoidCollisions}
collisionBoundary={collisionBoundary}
collisionPadding={collisionPadding}
arrowPadding={arrowPadding}
sticky={sticky}
hideWhenDetached={hideWhenDetached}
>
<motion.div
key="dropdown-menu-content"
data-slot="dropdown-menu-content"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={transition}
style={{ willChange: 'opacity, transform', ...style }}
{...props}
/>
</DropdownMenuPrimitive.Content>
</DropdownMenuPortal>
)}
</AnimatePresence>
);
}
type DropdownMenuHighlightItemProps = HighlightItemProps;
function DropdownMenuHighlightItem(props: DropdownMenuHighlightItemProps) {
return <HighlightItem data-slot="dropdown-menu-highlight-item" {...props} />;
}
type DropdownMenuItemProps = Omit<
React.ComponentProps<typeof DropdownMenuPrimitive.Item>,
'asChild'
> &
HTMLMotionProps<'div'>;
function DropdownMenuItem({
disabled,
onSelect,
textValue,
...props
}: DropdownMenuItemProps) {
const { setHighlightedValue } = useDropdownMenu();
const [, highlightedRef] = useDataState<HTMLDivElement>(
'highlighted',
undefined,
(value) => {
if (value === true) {
const el = highlightedRef.current;
const v = el?.dataset.value || el?.id || null;
if (v) setHighlightedValue(v);
}
},
);
return (
<DropdownMenuPrimitive.Item
ref={highlightedRef}
disabled={disabled}
onSelect={onSelect}
textValue={textValue}
asChild
>
<motion.div
data-slot="dropdown-menu-item"
data-disabled={disabled}
{...props}
/>
</DropdownMenuPrimitive.Item>
);
}
type DropdownMenuCheckboxItemProps = Omit<
React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>,
'asChild'
> &
HTMLMotionProps<'div'>;
function DropdownMenuCheckboxItem({
checked,
onCheckedChange,
disabled,
onSelect,
textValue,
...props
}: DropdownMenuCheckboxItemProps) {
const { setHighlightedValue } = useDropdownMenu();
const [, highlightedRef] = useDataState<HTMLDivElement>(
'highlighted',
undefined,
(value) => {
if (value === true) {
const el = highlightedRef.current;
const v = el?.dataset.value || el?.id || null;
if (v) setHighlightedValue(v);
}
},
);
return (
<DropdownMenuPrimitive.CheckboxItem
ref={highlightedRef}
checked={checked}
onCheckedChange={onCheckedChange}
disabled={disabled}
onSelect={onSelect}
textValue={textValue}
asChild
>
<motion.div
data-slot="dropdown-menu-checkbox-item"
data-disabled={disabled}
{...props}
/>
</DropdownMenuPrimitive.CheckboxItem>
);
}
type DropdownMenuRadioItemProps = Omit<
React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>,
'asChild'
> &
HTMLMotionProps<'div'>;
function DropdownMenuRadioItem({
value,
disabled,
onSelect,
textValue,
...props
}: DropdownMenuRadioItemProps) {
const { setHighlightedValue } = useDropdownMenu();
const [, highlightedRef] = useDataState<HTMLDivElement>(
'highlighted',
undefined,
(value) => {
if (value === true) {
const el = highlightedRef.current;
const v = el?.dataset.value || el?.id || null;
if (v) setHighlightedValue(v);
}
},
);
return (
<DropdownMenuPrimitive.RadioItem
ref={highlightedRef}
value={value}
disabled={disabled}
onSelect={onSelect}
textValue={textValue}
asChild
>
<motion.div
data-slot="dropdown-menu-radio-item"
data-disabled={disabled}
{...props}
/>
</DropdownMenuPrimitive.RadioItem>
);
}
type DropdownMenuLabelProps = React.ComponentProps<
typeof DropdownMenuPrimitive.Label
>;
function DropdownMenuLabel(props: DropdownMenuLabelProps) {
return (
<DropdownMenuPrimitive.Label data-slot="dropdown-menu-label" {...props} />
);
}
type DropdownMenuSeparatorProps = React.ComponentProps<
typeof DropdownMenuPrimitive.Separator
>;
function DropdownMenuSeparator(props: DropdownMenuSeparatorProps) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
{...props}
/>
);
}
type DropdownMenuShortcutProps = React.ComponentProps<'span'>;
function DropdownMenuShortcut(props: DropdownMenuShortcutProps) {
return <span data-slot="dropdown-menu-shortcut" {...props} />;
}
type DropdownMenuItemIndicatorProps = Omit<
React.ComponentProps<typeof DropdownMenuPrimitive.ItemIndicator>,
'asChild'
> &
HTMLMotionProps<'div'>;
function DropdownMenuItemIndicator(props: DropdownMenuItemIndicatorProps) {
return (
<DropdownMenuPrimitive.ItemIndicator
data-slot="dropdown-menu-item-indicator"
asChild
>
<motion.div {...props} />
</DropdownMenuPrimitive.ItemIndicator>
);
}
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuHighlight,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuItemIndicator,
DropdownMenuHighlightItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
useDropdownMenu,
useDropdownMenuSub,
type DropdownMenuProps,
type DropdownMenuTriggerProps,
type DropdownMenuHighlightProps,
type DropdownMenuContentProps,
type DropdownMenuItemProps,
type DropdownMenuItemIndicatorProps,
type DropdownMenuHighlightItemProps,
type DropdownMenuCheckboxItemProps,
type DropdownMenuRadioItemProps,
type DropdownMenuLabelProps,
type DropdownMenuSeparatorProps,
type DropdownMenuShortcutProps,
type DropdownMenuGroupProps,
type DropdownMenuPortalProps,
type DropdownMenuSubProps,
type DropdownMenuSubContentProps,
type DropdownMenuSubTriggerProps,
type DropdownMenuRadioGroupProps,
type DropdownMenuContextType,
type DropdownMenuSubContextType,
};
@@ -0,0 +1,207 @@
'use client';
import * as React from 'react';
import { HoverCard as HoverCardPrimitive } from 'radix-ui';
import {
AnimatePresence,
motion,
useMotionValue,
useSpring,
type MotionValue,
type HTMLMotionProps,
type SpringOptions,
} from 'motion/react';
import { getStrictContext } from '@/lib/get-strict-context';
import { useControlledState } from '@/hooks/use-controlled-state';
type HoverCardContextType = {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
x: MotionValue<number>;
y: MotionValue<number>;
followCursor?: boolean | 'x' | 'y';
followCursorSpringOptions?: SpringOptions;
};
const [HoverCardProvider, useHoverCard] =
getStrictContext<HoverCardContextType>('HoverCardContext');
type HoverCardProps = React.ComponentProps<typeof HoverCardPrimitive.Root> & {
followCursor?: boolean | 'x' | 'y';
followCursorSpringOptions?: SpringOptions;
};
function HoverCard({
followCursor = false,
followCursorSpringOptions = { stiffness: 200, damping: 17 },
...props
}: HoverCardProps) {
const [isOpen, setIsOpen] = useControlledState({
value: props?.open,
defaultValue: props?.defaultOpen,
onChange: props?.onOpenChange,
});
const x = useMotionValue(0);
const y = useMotionValue(0);
return (
<HoverCardProvider
value={{
isOpen,
setIsOpen,
x,
y,
followCursor,
followCursorSpringOptions,
}}
>
<HoverCardPrimitive.Root
data-slot="hover-card"
{...props}
onOpenChange={setIsOpen}
/>
</HoverCardProvider>
);
}
type HoverCardTriggerProps = React.ComponentProps<
typeof HoverCardPrimitive.Trigger
>;
function HoverCardTrigger({ onMouseMove, ...props }: HoverCardTriggerProps) {
const { x, y, followCursor } = useHoverCard();
const handleMouseMove = (event: React.MouseEvent<HTMLAnchorElement>) => {
onMouseMove?.(event);
const target = event.currentTarget.getBoundingClientRect();
if (followCursor === 'x' || followCursor === true) {
const eventOffsetX = event.clientX - target.left;
const offsetXFromCenter = (eventOffsetX - target.width / 2) / 2;
x.set(offsetXFromCenter);
}
if (followCursor === 'y' || followCursor === true) {
const eventOffsetY = event.clientY - target.top;
const offsetYFromCenter = (eventOffsetY - target.height / 2) / 2;
y.set(offsetYFromCenter);
}
};
return (
<HoverCardPrimitive.Trigger
data-slot="hover-card-trigger"
onMouseMove={handleMouseMove}
{...props}
/>
);
}
type HoverCardPortalProps = Omit<
React.ComponentProps<typeof HoverCardPrimitive.Portal>,
'forceMount'
>;
function HoverCardPortal(props: HoverCardPortalProps) {
const { isOpen } = useHoverCard();
return (
<AnimatePresence>
{isOpen && (
<HoverCardPrimitive.Portal
forceMount
data-slot="hover-card-portal"
{...props}
/>
)}
</AnimatePresence>
);
}
type HoverCardContentProps = React.ComponentProps<
typeof HoverCardPrimitive.Content
> &
HTMLMotionProps<'div'>;
function HoverCardContent({
align,
alignOffset,
side,
sideOffset,
avoidCollisions,
collisionBoundary,
collisionPadding,
arrowPadding,
sticky,
hideWhenDetached,
style,
transition = { type: 'spring', stiffness: 300, damping: 25 },
...props
}: HoverCardContentProps) {
const { x, y, followCursor, followCursorSpringOptions } = useHoverCard();
const translateX = useSpring(x, followCursorSpringOptions);
const translateY = useSpring(y, followCursorSpringOptions);
return (
<HoverCardPrimitive.Content
asChild
forceMount
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
avoidCollisions={avoidCollisions}
collisionBoundary={collisionBoundary}
collisionPadding={collisionPadding}
arrowPadding={arrowPadding}
sticky={sticky}
hideWhenDetached={hideWhenDetached}
>
<motion.div
key="hover-card-content"
data-slot="hover-card-content"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={transition}
style={{
x:
followCursor === 'x' || followCursor === true
? translateX
: undefined,
y:
followCursor === 'y' || followCursor === true
? translateY
: undefined,
...style,
}}
{...props}
/>
</HoverCardPrimitive.Content>
);
}
type HoverCardArrowProps = React.ComponentProps<
typeof HoverCardPrimitive.Arrow
>;
function HoverCardArrow(props: HoverCardArrowProps) {
return <HoverCardPrimitive.Arrow data-slot="hover-card-arrow" {...props} />;
}
export {
HoverCard,
HoverCardTrigger,
HoverCardPortal,
HoverCardContent,
HoverCardArrow,
useHoverCard,
type HoverCardProps,
type HoverCardTriggerProps,
type HoverCardPortalProps,
type HoverCardContentProps,
type HoverCardArrowProps,
type HoverCardContextType,
};
@@ -0,0 +1,162 @@
'use client';
import * as React from 'react';
import { Popover as PopoverPrimitive } from 'radix-ui';
import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
import { getStrictContext } from '@/lib/get-strict-context';
import { useControlledState } from '@/hooks/use-controlled-state';
type PopoverContextType = {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
};
const [PopoverProvider, usePopover] =
getStrictContext<PopoverContextType>('PopoverContext');
type PopoverProps = React.ComponentProps<typeof PopoverPrimitive.Root>;
function Popover(props: PopoverProps) {
const [isOpen, setIsOpen] = useControlledState({
value: props?.open,
defaultValue: props?.defaultOpen,
onChange: props?.onOpenChange,
});
return (
<PopoverProvider value={{ isOpen, setIsOpen }}>
<PopoverPrimitive.Root
data-slot="popover"
{...props}
onOpenChange={setIsOpen}
/>
</PopoverProvider>
);
}
type PopoverTriggerProps = React.ComponentProps<
typeof PopoverPrimitive.Trigger
>;
function PopoverTrigger(props: PopoverTriggerProps) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
type PopoverPortalProps = Omit<
React.ComponentProps<typeof PopoverPrimitive.Portal>,
'forceMount'
>;
function PopoverPortal(props: PopoverPortalProps) {
const { isOpen } = usePopover();
return (
<AnimatePresence>
{isOpen && (
<PopoverPrimitive.Portal
forceMount
data-slot="popover-portal"
{...props}
/>
)}
</AnimatePresence>
);
}
type PopoverContentProps = Omit<
React.ComponentProps<typeof PopoverPrimitive.Content>,
'forceMount' | 'asChild'
> &
HTMLMotionProps<'div'>;
function PopoverContent({
onOpenAutoFocus,
onCloseAutoFocus,
onEscapeKeyDown,
onPointerDownOutside,
onFocusOutside,
onInteractOutside,
align,
alignOffset,
side,
sideOffset,
avoidCollisions,
collisionBoundary,
collisionPadding,
arrowPadding,
sticky,
hideWhenDetached,
transition = { type: 'spring', stiffness: 300, damping: 25 },
...props
}: PopoverContentProps) {
return (
<PopoverPrimitive.Content
asChild
forceMount
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
avoidCollisions={avoidCollisions}
collisionBoundary={collisionBoundary}
collisionPadding={collisionPadding}
arrowPadding={arrowPadding}
sticky={sticky}
hideWhenDetached={hideWhenDetached}
onOpenAutoFocus={onOpenAutoFocus}
onCloseAutoFocus={onCloseAutoFocus}
onEscapeKeyDown={onEscapeKeyDown}
onPointerDownOutside={onPointerDownOutside}
onInteractOutside={onInteractOutside}
onFocusOutside={onFocusOutside}
>
<motion.div
key="popover-content"
data-slot="popover-content"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={transition}
{...props}
/>
</PopoverPrimitive.Content>
);
}
type PopoverAnchorProps = React.ComponentProps<typeof PopoverPrimitive.Anchor>;
function PopoverAnchor({ ...props }: PopoverAnchorProps) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
type PopoverArrowProps = React.ComponentProps<typeof PopoverPrimitive.Arrow>;
function PopoverArrow(props: PopoverArrowProps) {
return <PopoverPrimitive.Arrow data-slot="popover-arrow" {...props} />;
}
type PopoverCloseProps = React.ComponentProps<typeof PopoverPrimitive.Close>;
function PopoverClose(props: PopoverCloseProps) {
return <PopoverPrimitive.Close data-slot="popover-close" {...props} />;
}
export {
Popover,
PopoverTrigger,
PopoverPortal,
PopoverContent,
PopoverAnchor,
PopoverClose,
PopoverArrow,
usePopover,
type PopoverProps,
type PopoverTriggerProps,
type PopoverPortalProps,
type PopoverContentProps,
type PopoverAnchorProps,
type PopoverCloseProps,
type PopoverArrowProps,
type PopoverContextType,
};
@@ -0,0 +1,191 @@
'use client';
import * as React from 'react';
import { Dialog as SheetPrimitive } from 'radix-ui';
import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
import { getStrictContext } from '@/lib/get-strict-context';
import { useControlledState } from '@/hooks/use-controlled-state';
type SheetContextType = {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
};
const [SheetProvider, useSheet] =
getStrictContext<SheetContextType>('SheetContext');
type SheetProps = React.ComponentProps<typeof SheetPrimitive.Root>;
function Sheet(props: SheetProps) {
const [isOpen, setIsOpen] = useControlledState({
value: props.open,
defaultValue: props.defaultOpen,
onChange: props.onOpenChange,
});
return (
<SheetProvider value={{ isOpen, setIsOpen }}>
<SheetPrimitive.Root
data-slot="sheet"
{...props}
onOpenChange={setIsOpen}
/>
</SheetProvider>
);
}
type SheetTriggerProps = React.ComponentProps<typeof SheetPrimitive.Trigger>;
function SheetTrigger(props: SheetTriggerProps) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
type SheetCloseProps = React.ComponentProps<typeof SheetPrimitive.Close>;
function SheetClose(props: SheetCloseProps) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
type SheetPortalProps = React.ComponentProps<typeof SheetPrimitive.Portal>;
function SheetPortal(props: SheetPortalProps) {
const { isOpen } = useSheet();
return (
<AnimatePresence>
{isOpen && (
<SheetPrimitive.Portal forceMount data-slot="sheet-portal" {...props} />
)}
</AnimatePresence>
);
}
type SheetOverlayProps = Omit<
React.ComponentProps<typeof SheetPrimitive.Overlay>,
'asChild' | 'forceMount'
> &
HTMLMotionProps<'div'>;
function SheetOverlay({
transition = { duration: 0.2, ease: 'easeInOut' },
...props
}: SheetOverlayProps) {
return (
<SheetPrimitive.Overlay asChild forceMount>
<motion.div
key="sheet-overlay"
data-slot="sheet-overlay"
initial={{ opacity: 0, filter: 'blur(4px)' }}
animate={{ opacity: 1, filter: 'blur(0px)' }}
exit={{ opacity: 0, filter: 'blur(4px)' }}
transition={transition}
{...props}
/>
</SheetPrimitive.Overlay>
);
}
type Side = 'top' | 'bottom' | 'left' | 'right';
type SheetContentProps = React.ComponentProps<typeof SheetPrimitive.Content> &
HTMLMotionProps<'div'> & {
side?: Side;
};
function SheetContent({
side = 'right',
transition = { type: 'spring', stiffness: 150, damping: 22 },
style,
children,
...props
}: SheetContentProps) {
const axis = side === 'left' || side === 'right' ? 'x' : 'y';
const offscreen: Record<Side, { x?: string; y?: string; opacity: number }> = {
right: { x: '100%', opacity: 0 },
left: { x: '-100%', opacity: 0 },
top: { y: '-100%', opacity: 0 },
bottom: { y: '100%', opacity: 0 },
};
const positionStyle: Record<Side, React.CSSProperties> = {
right: { insetBlock: 0, right: 0 },
left: { insetBlock: 0, left: 0 },
top: { insetInline: 0, top: 0 },
bottom: { insetInline: 0, bottom: 0 },
};
return (
<SheetPrimitive.Content asChild forceMount {...props}>
<motion.div
key="sheet-content"
data-slot="sheet-content"
data-side={side}
initial={offscreen[side]}
animate={{ [axis]: 0, opacity: 1 }}
exit={offscreen[side]}
style={{
position: 'fixed',
...positionStyle[side],
...style,
}}
transition={transition}
>
{children}
</motion.div>
</SheetPrimitive.Content>
);
}
type SheetHeaderProps = React.ComponentProps<'div'>;
function SheetHeader(props: SheetHeaderProps) {
return <div data-slot="sheet-header" {...props} />;
}
type SheetFooterProps = React.ComponentProps<'div'>;
function SheetFooter(props: SheetFooterProps) {
return <div data-slot="sheet-footer" {...props} />;
}
type SheetTitleProps = React.ComponentProps<typeof SheetPrimitive.Title>;
function SheetTitle(props: SheetTitleProps) {
return <SheetPrimitive.Title data-slot="sheet-title" {...props} />;
}
type SheetDescriptionProps = React.ComponentProps<
typeof SheetPrimitive.Description
>;
function SheetDescription(props: SheetDescriptionProps) {
return (
<SheetPrimitive.Description data-slot="sheet-description" {...props} />
);
}
export {
useSheet,
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
type SheetProps,
type SheetPortalProps,
type SheetOverlayProps,
type SheetTriggerProps,
type SheetCloseProps,
type SheetContentProps,
type SheetHeaderProps,
type SheetFooterProps,
type SheetTitleProps,
type SheetDescriptionProps,
};
@@ -0,0 +1,155 @@
'use client';
import * as React from 'react';
import { Switch as SwitchPrimitives } from 'radix-ui';
import {
motion,
type TargetAndTransition,
type VariantLabels,
type HTMLMotionProps,
type LegacyAnimationControls,
} from 'motion/react';
import { getStrictContext } from '@/lib/get-strict-context';
import { useControlledState } from '@/hooks/use-controlled-state';
type SwitchContextType = {
isChecked: boolean;
setIsChecked: (isChecked: boolean) => void;
isPressed: boolean;
setIsPressed: (isPressed: boolean) => void;
};
const [SwitchProvider, useSwitch] =
getStrictContext<SwitchContextType>('SwitchContext');
type SwitchProps = Omit<
React.ComponentProps<typeof SwitchPrimitives.Root>,
'asChild'
> &
HTMLMotionProps<'button'>;
function Switch(props: SwitchProps) {
// Destructure Radix-only props so they don't leak onto the motion.button DOM element
const {
checked,
defaultChecked,
onCheckedChange,
disabled,
required,
name,
value,
form,
...motionProps
} = props;
const [isPressed, setIsPressed] = React.useState(false);
const [isChecked, setIsChecked] = useControlledState({
value: checked,
defaultValue: defaultChecked,
onChange: onCheckedChange,
});
return (
<SwitchProvider
value={{ isChecked, setIsChecked, isPressed, setIsPressed }}
>
<SwitchPrimitives.Root
checked={checked}
defaultChecked={defaultChecked}
onCheckedChange={setIsChecked}
disabled={disabled}
required={required}
name={name}
value={value}
form={form}
asChild
>
<motion.button
data-slot="switch"
whileTap="tap"
initial={false}
onTapStart={() => setIsPressed(true)}
onTapCancel={() => setIsPressed(false)}
onTap={() => setIsPressed(false)}
{...motionProps}
/>
</SwitchPrimitives.Root>
</SwitchProvider>
);
}
type SwitchThumbProps = Omit<
React.ComponentProps<typeof SwitchPrimitives.Thumb>,
'asChild'
> &
HTMLMotionProps<'div'> & {
pressedAnimation?:
| TargetAndTransition
| VariantLabels
| boolean
| LegacyAnimationControls;
};
function SwitchThumb({
pressedAnimation,
transition = { type: 'spring', stiffness: 300, damping: 25 },
...props
}: SwitchThumbProps) {
const { isPressed } = useSwitch();
return (
<SwitchPrimitives.Thumb asChild>
<motion.div
data-slot="switch-thumb"
whileTap="tab"
layout
transition={transition}
animate={isPressed ? pressedAnimation : undefined}
{...props}
/>
</SwitchPrimitives.Thumb>
);
}
type SwitchIconPosition = 'left' | 'right' | 'thumb';
type SwitchIconProps = HTMLMotionProps<'div'> & {
position: SwitchIconPosition;
};
function SwitchIcon({
position,
transition = { type: 'spring', bounce: 0 },
...props
}: SwitchIconProps) {
const { isChecked } = useSwitch();
const isAnimated = React.useMemo(() => {
if (position === 'right') return !isChecked;
if (position === 'left') return isChecked;
if (position === 'thumb') return true;
return false;
}, [position, isChecked]);
return (
<motion.div
data-slot={`switch-${position}-icon`}
animate={isAnimated ? { scale: 1, opacity: 1 } : { scale: 0, opacity: 0 }}
transition={transition}
{...props}
/>
);
}
export {
Switch,
SwitchThumb,
SwitchIcon,
useSwitch,
type SwitchProps,
type SwitchThumbProps,
type SwitchIconProps,
type SwitchIconPosition,
type SwitchContextType,
};
@@ -0,0 +1,189 @@
'use client';
import * as React from 'react';
import { Tabs as TabsPrimitive } from 'radix-ui';
import {
motion,
AnimatePresence,
type HTMLMotionProps,
type Transition,
} from 'motion/react';
import {
Highlight,
HighlightItem,
type HighlightProps,
type HighlightItemProps,
} from '@/components/animate-ui/primitives/effects/highlight';
import { getStrictContext } from '@/lib/get-strict-context';
import { useControlledState } from '@/hooks/use-controlled-state';
import {
AutoHeight,
type AutoHeightProps,
} from '@/components/animate-ui/primitives/effects/auto-height';
type TabsContextType = {
value: string | undefined;
setValue: TabsProps['onValueChange'];
};
const [TabsProvider, useTabs] =
getStrictContext<TabsContextType>('TabsContext');
type TabsProps = React.ComponentProps<typeof TabsPrimitive.Root>;
function Tabs(props: TabsProps) {
const [value, setValue] = useControlledState({
value: props.value,
defaultValue: props.defaultValue,
onChange: props.onValueChange,
});
return (
<TabsProvider value={{ value, setValue }}>
<TabsPrimitive.Root
data-slot="tabs"
{...props}
onValueChange={setValue}
/>
</TabsProvider>
);
}
type TabsHighlightProps = Omit<HighlightProps, 'controlledItems' | 'value'>;
function TabsHighlight({
transition = { type: 'spring', stiffness: 200, damping: 25 },
...props
}: TabsHighlightProps) {
const { value } = useTabs();
return (
<Highlight
data-slot="tabs-highlight"
controlledItems
value={value}
transition={transition}
click={false}
{...props}
/>
);
}
type TabsListProps = React.ComponentProps<typeof TabsPrimitive.List>;
function TabsList(props: TabsListProps) {
return <TabsPrimitive.List data-slot="tabs-list" {...props} />;
}
type TabsHighlightItemProps = HighlightItemProps & {
value: string;
};
function TabsHighlightItem(props: TabsHighlightItemProps) {
return <HighlightItem data-slot="tabs-highlight-item" {...props} />;
}
type TabsTriggerProps = React.ComponentProps<typeof TabsPrimitive.Trigger>;
function TabsTrigger(props: TabsTriggerProps) {
return <TabsPrimitive.Trigger data-slot="tabs-trigger" {...props} />;
}
type TabsContentProps = React.ComponentProps<typeof TabsPrimitive.Content> &
HTMLMotionProps<'div'>;
function TabsContent({
value,
forceMount,
transition = { duration: 0.5, ease: 'easeInOut' },
...props
}: TabsContentProps) {
return (
<AnimatePresence mode="wait">
<TabsPrimitive.Content asChild forceMount={forceMount} value={value}>
<motion.div
data-slot="tabs-content"
layout
layoutDependency={value}
initial={{ opacity: 0, filter: 'blur(4px)' }}
animate={{ opacity: 1, filter: 'blur(0px)' }}
exit={{ opacity: 0, filter: 'blur(4px)' }}
transition={transition}
{...props}
/>
</TabsPrimitive.Content>
</AnimatePresence>
);
}
type TabsContentsAutoProps = AutoHeightProps & {
mode?: 'auto-height';
children: React.ReactNode;
transition?: Transition;
};
type TabsContentsLayoutProps = Omit<HTMLMotionProps<'div'>, 'transition'> & {
mode: 'layout';
children: React.ReactNode;
transition?: Transition;
};
type TabsContentsProps = TabsContentsAutoProps | TabsContentsLayoutProps;
const defaultTransition: Transition = {
type: 'spring',
stiffness: 200,
damping: 30,
};
function isAutoMode(props: TabsContentsProps): props is TabsContentsAutoProps {
return !('mode' in props) || props.mode === 'auto-height';
}
function TabsContents(props: TabsContentsProps) {
const { value } = useTabs();
if (isAutoMode(props)) {
const { transition = defaultTransition, ...autoProps } = props;
return (
<AutoHeight
data-slot="tabs-contents"
deps={[value]}
transition={transition}
{...autoProps}
/>
);
}
const { transition = defaultTransition, style, ...layoutProps } = props;
return (
<motion.div
data-slot="tabs-contents"
layout="size"
layoutDependency={value}
style={{ overflow: 'hidden', ...style }}
transition={{ layout: transition }}
{...layoutProps}
/>
);
}
export {
Tabs,
TabsHighlight,
TabsHighlightItem,
TabsList,
TabsTrigger,
TabsContent,
TabsContents,
type TabsProps,
type TabsHighlightProps,
type TabsHighlightItemProps,
type TabsListProps,
type TabsTriggerProps,
type TabsContentProps,
type TabsContentsProps,
};
@@ -0,0 +1,220 @@
'use client';
import * as React from 'react';
import { Tooltip as TooltipPrimitive } from 'radix-ui';
import {
AnimatePresence,
motion,
useMotionValue,
useSpring,
type SpringOptions,
type HTMLMotionProps,
type MotionValue,
} from 'motion/react';
import { getStrictContext } from '@/lib/get-strict-context';
import { useControlledState } from '@/hooks/use-controlled-state';
type TooltipContextType = {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
x: MotionValue<number>;
y: MotionValue<number>;
followCursor?: boolean | 'x' | 'y';
followCursorSpringOptions?: SpringOptions;
};
const [LocalTooltipProvider, useTooltip] =
getStrictContext<TooltipContextType>('TooltipContext');
type TooltipProviderProps = React.ComponentProps<
typeof TooltipPrimitive.Provider
>;
function TooltipProvider(props: TooltipProviderProps) {
return <TooltipPrimitive.Provider data-slot="tooltip-provider" {...props} />;
}
type TooltipProps = React.ComponentProps<typeof TooltipPrimitive.Root> & {
followCursor?: boolean | 'x' | 'y';
followCursorSpringOptions?: SpringOptions;
};
function Tooltip({
followCursor = false,
followCursorSpringOptions = { stiffness: 200, damping: 17 },
...props
}: TooltipProps) {
const [isOpen, setIsOpen] = useControlledState({
value: props?.open,
defaultValue: props?.defaultOpen,
onChange: props?.onOpenChange,
});
const x = useMotionValue(0);
const y = useMotionValue(0);
return (
<LocalTooltipProvider
value={{
isOpen,
setIsOpen,
x,
y,
followCursor,
followCursorSpringOptions,
}}
>
<TooltipPrimitive.Root
data-slot="tooltip"
{...props}
onOpenChange={setIsOpen}
/>
</LocalTooltipProvider>
);
}
type TooltipTriggerProps = React.ComponentProps<
typeof TooltipPrimitive.Trigger
>;
function TooltipTrigger({ onMouseMove, ...props }: TooltipTriggerProps) {
const { x, y, followCursor } = useTooltip();
const handleMouseMove = (event: React.MouseEvent<HTMLButtonElement>) => {
onMouseMove?.(event);
const target = event.currentTarget.getBoundingClientRect();
if (followCursor === 'x' || followCursor === true) {
const eventOffsetX = event.clientX - target.left;
const offsetXFromCenter = (eventOffsetX - target.width / 2) / 2;
x.set(offsetXFromCenter);
}
if (followCursor === 'y' || followCursor === true) {
const eventOffsetY = event.clientY - target.top;
const offsetYFromCenter = (eventOffsetY - target.height / 2) / 2;
y.set(offsetYFromCenter);
}
};
return (
<TooltipPrimitive.Trigger
data-slot="tooltip-trigger"
onMouseMove={handleMouseMove}
{...props}
/>
);
}
type TooltipPortalProps = Omit<
React.ComponentProps<typeof TooltipPrimitive.Portal>,
'forceMount'
>;
function TooltipPortal(props: TooltipPortalProps) {
const { isOpen } = useTooltip();
return (
<AnimatePresence>
{isOpen && (
<TooltipPrimitive.Portal
forceMount
data-slot="tooltip-portal"
{...props}
/>
)}
</AnimatePresence>
);
}
type TooltipContentProps = Omit<
React.ComponentProps<typeof TooltipPrimitive.Content>,
'forceMount' | 'asChild'
> &
HTMLMotionProps<'div'>;
function TooltipContent({
onEscapeKeyDown,
onPointerDownOutside,
side,
sideOffset,
align,
alignOffset,
avoidCollisions,
collisionBoundary,
collisionPadding,
arrowPadding,
sticky,
hideWhenDetached,
style,
transition = { type: 'spring', stiffness: 300, damping: 25 },
...props
}: TooltipContentProps) {
const { x, y, followCursor, followCursorSpringOptions } = useTooltip();
const translateX = useSpring(x, followCursorSpringOptions);
const translateY = useSpring(y, followCursorSpringOptions);
return (
<TooltipPrimitive.Content
asChild
forceMount
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
avoidCollisions={avoidCollisions}
collisionBoundary={collisionBoundary}
collisionPadding={collisionPadding}
arrowPadding={arrowPadding}
sticky={sticky}
hideWhenDetached={hideWhenDetached}
onEscapeKeyDown={onEscapeKeyDown}
onPointerDownOutside={onPointerDownOutside}
>
<motion.div
key="popover-content"
data-slot="popover-content"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={transition}
style={{
x:
followCursor === 'x' || followCursor === true
? translateX
: undefined,
y:
followCursor === 'y' || followCursor === true
? translateY
: undefined,
...style,
}}
{...props}
/>
</TooltipPrimitive.Content>
);
}
type TooltipArrowProps = React.ComponentProps<typeof TooltipPrimitive.Arrow>;
function TooltipArrow(props: TooltipArrowProps) {
return <TooltipPrimitive.Arrow data-slot="tooltip-arrow" {...props} />;
}
export {
TooltipProvider,
Tooltip,
TooltipTrigger,
TooltipPortal,
TooltipContent,
TooltipArrow,
useTooltip,
type TooltipProviderProps,
type TooltipProps,
type TooltipTriggerProps,
type TooltipPortalProps,
type TooltipContentProps,
type TooltipArrowProps,
type TooltipContextType,
};
@@ -0,0 +1,119 @@
'use client';
import * as React from 'react';
import { useMotionValue, useSpring, type SpringOptions } from 'motion/react';
import {
useIsInView,
type UseIsInViewOptions,
} from '@/hooks/use-is-in-view';
type CountingNumberProps = Omit<React.ComponentProps<'span'>, 'children'> & {
number: number;
fromNumber?: number;
padStart?: boolean;
decimalSeparator?: string;
decimalPlaces?: number;
transition?: SpringOptions;
delay?: number;
initiallyStable?: boolean;
} & UseIsInViewOptions;
function CountingNumber({
ref,
number,
fromNumber = 0,
padStart = false,
inView = false,
inViewMargin = '0px',
inViewOnce = true,
decimalSeparator = '.',
transition = { stiffness: 90, damping: 50 },
decimalPlaces = 0,
delay = 0,
initiallyStable = false,
...props
}: CountingNumberProps) {
const { ref: localRef, isInView } = useIsInView(
ref as React.Ref<HTMLElement>,
{
inView,
inViewOnce,
inViewMargin,
},
);
const numberStr = number.toString();
const decimals =
typeof decimalPlaces === 'number'
? decimalPlaces
: numberStr.includes('.')
? (numberStr.split('.')[1]?.length ?? 0)
: 0;
const motionVal = useMotionValue(initiallyStable ? number : fromNumber);
const springVal = useSpring(motionVal, transition);
React.useEffect(() => {
const timeoutId = setTimeout(() => {
if (isInView) motionVal.set(number);
}, delay);
return () => clearTimeout(timeoutId);
}, [isInView, number, motionVal, delay]);
React.useEffect(() => {
const unsubscribe = springVal.on('change', (latest) => {
if (localRef.current) {
let formatted =
decimals > 0
? latest.toFixed(decimals)
: Math.round(latest).toString();
if (decimals > 0) {
formatted = formatted.replace('.', decimalSeparator);
}
if (padStart) {
const finalIntLength = Math.floor(Math.abs(number)).toString().length;
const [intPart, fracPart] = formatted.split(decimalSeparator);
const paddedInt = intPart?.padStart(finalIntLength, '0') ?? '';
formatted = fracPart
? `${paddedInt}${decimalSeparator}${fracPart}`
: paddedInt;
}
localRef.current.textContent = formatted;
}
});
return () => unsubscribe();
}, [springVal, decimals, padStart, number, decimalSeparator, localRef]);
const finalIntLength = Math.floor(Math.abs(number)).toString().length;
const formatValue = (val: number) => {
let out = decimals > 0 ? val.toFixed(decimals) : Math.round(val).toString();
if (decimals > 0) out = out.replace('.', decimalSeparator);
if (padStart) {
const [intPart, fracPart] = out.split(decimalSeparator);
const paddedInt = (intPart ?? '').padStart(finalIntLength, '0');
out = fracPart ? `${paddedInt}${decimalSeparator}${fracPart}` : paddedInt;
}
return out;
};
const zeroText = padStart
? '0'.padStart(finalIntLength, '0') +
(decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : '')
: '0' + (decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : '');
const initialText = initiallyStable ? formatValue(number) : zeroText;
return (
<span ref={localRef} data-slot="counting-number" {...props}>
{initialText}
</span>
);
}
export { CountingNumber, type CountingNumberProps };
@@ -0,0 +1,353 @@
'use client';
import * as React from 'react';
import {
useSpring,
useTransform,
motion,
useMotionValue,
type MotionValue,
type SpringOptions,
type HTMLMotionProps,
} from 'motion/react';
import useMeasure from 'react-use-measure';
import {
useIsInView,
type UseIsInViewOptions,
} from '@/hooks/use-is-in-view';
type SlidingNumberRollerProps = {
prevValue: number;
value: number;
place: number;
transition: SpringOptions;
delay?: number;
};
function SlidingNumberRoller({
prevValue,
value,
place,
transition,
delay = 0,
}: SlidingNumberRollerProps) {
const startNumber = Math.floor(prevValue / place) % 10;
const targetNumber = Math.floor(value / place) % 10;
const animatedValue = useSpring(startNumber, transition);
React.useEffect(() => {
const timeoutId = setTimeout(() => {
animatedValue.set(targetNumber);
}, delay);
return () => clearTimeout(timeoutId);
}, [targetNumber, animatedValue, delay]);
const [measureRef, { height }] = useMeasure();
return (
<span
ref={measureRef}
data-slot="sliding-number-roller"
style={{
position: 'relative',
display: 'inline-block',
width: '1ch',
overflowX: 'visible',
overflowY: 'clip',
lineHeight: 1,
fontVariantNumeric: 'tabular-nums',
}}
>
<span style={{ visibility: 'hidden' }}>0</span>
{Array.from({ length: 10 }, (_, i) => (
<SlidingNumberDisplay
key={i}
motionValue={animatedValue}
number={i}
height={height}
transition={transition}
/>
))}
</span>
);
}
type SlidingNumberDisplayProps = {
motionValue: MotionValue<number>;
number: number;
height: number;
transition: SpringOptions;
};
function SlidingNumberDisplay({
motionValue,
number,
height,
transition,
}: SlidingNumberDisplayProps) {
const y = useTransform(motionValue, (latest) => {
if (!height) return 0;
const currentNumber = latest % 10;
const offset = (10 + number - currentNumber) % 10;
let translateY = offset * height;
if (offset > 5) translateY -= 10 * height;
return translateY;
});
if (!height) {
return (
<span style={{ visibility: 'hidden', position: 'absolute' }}>
{number}
</span>
);
}
return (
<motion.span
data-slot="sliding-number-display"
style={{
y,
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
transition={{ ...transition, type: 'spring' }}
>
{number}
</motion.span>
);
}
type SlidingNumberProps = Omit<HTMLMotionProps<'span'>, 'children'> & {
number: number;
fromNumber?: number;
onNumberChange?: (number: number) => void;
padStart?: boolean;
decimalSeparator?: string;
decimalPlaces?: number;
thousandSeparator?: string;
transition?: SpringOptions;
delay?: number;
initiallyStable?: boolean;
} & UseIsInViewOptions;
function SlidingNumber({
ref,
number,
fromNumber,
onNumberChange,
inView = false,
inViewMargin = '0px',
inViewOnce = true,
padStart = false,
decimalSeparator = '.',
decimalPlaces = 0,
thousandSeparator,
transition = { stiffness: 200, damping: 20, mass: 0.4 },
delay = 0,
initiallyStable = false,
...props
}: SlidingNumberProps) {
const { ref: localRef, isInView } = useIsInView(
ref as React.Ref<HTMLElement>,
{
inView,
inViewOnce,
inViewMargin,
},
);
const initialNumeric = Math.abs(Number(number));
const prevNumberRef = React.useRef<number>(
initiallyStable ? initialNumeric : 0,
);
const hasAnimated = fromNumber !== undefined;
const motionVal = useMotionValue(
initiallyStable ? initialNumeric : (fromNumber ?? 0),
);
const springVal = useSpring(motionVal, { stiffness: 90, damping: 50 });
const skippedInitialWhenStable = React.useRef(false);
React.useEffect(() => {
if (!hasAnimated) return;
if (initiallyStable && !skippedInitialWhenStable.current) {
skippedInitialWhenStable.current = true;
return;
}
const timeoutId = setTimeout(() => {
if (isInView) motionVal.set(number);
}, delay);
return () => clearTimeout(timeoutId);
}, [hasAnimated, initiallyStable, isInView, number, motionVal, delay]);
const [effectiveNumber, setEffectiveNumber] = React.useState<number>(
initiallyStable ? initialNumeric : 0,
);
React.useEffect(() => {
if (hasAnimated) {
const inferredDecimals =
typeof decimalPlaces === 'number' && decimalPlaces >= 0
? decimalPlaces
: (() => {
const s = String(number);
const idx = s.indexOf('.');
return idx >= 0 ? s.length - idx - 1 : 0;
})();
const factor = Math.pow(10, inferredDecimals);
const unsubscribe = springVal.on('change', (latest: number) => {
const newValue =
inferredDecimals > 0
? Math.round(latest * factor) / factor
: Math.round(latest);
if (effectiveNumber !== newValue) {
setEffectiveNumber(newValue);
onNumberChange?.(newValue);
}
});
return () => unsubscribe();
} else {
setEffectiveNumber(
initiallyStable ? initialNumeric : !isInView ? 0 : initialNumeric,
);
}
}, [
hasAnimated,
springVal,
isInView,
number,
decimalPlaces,
onNumberChange,
effectiveNumber,
initiallyStable,
initialNumeric,
]);
const formatNumber = React.useCallback(
(num: number) =>
decimalPlaces != null ? num.toFixed(decimalPlaces) : num.toString(),
[decimalPlaces],
);
const numberStr = formatNumber(effectiveNumber);
const [newIntStrRaw, newDecStrRaw = ''] = numberStr.split('.');
const finalIntLength = padStart
? Math.max(
Math.floor(Math.abs(number)).toString().length,
newIntStrRaw.length,
)
: newIntStrRaw.length;
const newIntStr = padStart
? newIntStrRaw.padStart(finalIntLength, '0')
: newIntStrRaw;
const prevFormatted = formatNumber(prevNumberRef.current);
const [prevIntStrRaw = '', prevDecStrRaw = ''] = prevFormatted.split('.');
const prevIntStr = padStart
? prevIntStrRaw.padStart(finalIntLength, '0')
: prevIntStrRaw;
const adjustedPrevInt = React.useMemo(() => {
return prevIntStr.length > finalIntLength
? prevIntStr.slice(-finalIntLength)
: prevIntStr.padStart(finalIntLength, '0');
}, [prevIntStr, finalIntLength]);
const adjustedPrevDec = React.useMemo(() => {
if (!newDecStrRaw) return '';
return prevDecStrRaw.length > newDecStrRaw.length
? prevDecStrRaw.slice(0, newDecStrRaw.length)
: prevDecStrRaw.padEnd(newDecStrRaw.length, '0');
}, [prevDecStrRaw, newDecStrRaw]);
React.useEffect(() => {
if (isInView || initiallyStable) {
prevNumberRef.current = effectiveNumber;
}
}, [effectiveNumber, isInView, initiallyStable]);
const intPlaces = React.useMemo(
() =>
Array.from({ length: finalIntLength }, (_, i) =>
Math.pow(10, finalIntLength - i - 1),
),
[finalIntLength],
);
const decPlaces = React.useMemo(
() =>
newDecStrRaw
? Array.from({ length: newDecStrRaw.length }, (_, i) =>
Math.pow(10, newDecStrRaw.length - i - 1),
)
: [],
[newDecStrRaw],
);
const newDecValue = newDecStrRaw ? parseInt(newDecStrRaw, 10) : 0;
const prevDecValue = adjustedPrevDec ? parseInt(adjustedPrevDec, 10) : 0;
return (
<motion.span
ref={localRef}
data-slot="sliding-number"
style={{
display: 'inline-flex',
alignItems: 'center',
}}
{...props}
>
{isInView && Number(number) < 0 && (
<span style={{ marginRight: '0.25rem' }}>-</span>
)}
{intPlaces.map((place, idx) => {
const digitsToRight = intPlaces.length - idx - 1;
const isSeparatorPosition =
typeof thousandSeparator !== 'undefined' &&
digitsToRight > 0 &&
digitsToRight % 3 === 0;
return (
<React.Fragment key={`int-${place}`}>
<SlidingNumberRoller
prevValue={parseInt(adjustedPrevInt, 10)}
value={parseInt(newIntStr ?? '0', 10)}
place={place}
transition={transition}
/>
{isSeparatorPosition && <span>{thousandSeparator}</span>}
</React.Fragment>
);
})}
{newDecStrRaw && (
<>
<span>{decimalSeparator}</span>
{decPlaces.map((place) => (
<SlidingNumberRoller
key={`dec-${place}`}
prevValue={prevDecValue}
value={newDecValue}
place={place}
transition={transition}
delay={delay}
/>
))}
</>
)}
</motion.span>
);
}
export { SlidingNumber, type SlidingNumberProps };
+36 -93
View File
@@ -1,112 +1,55 @@
"use client"
'use client';
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import * as React from 'react';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
import {
Dialog,
DialogPortal,
DialogOverlay as AnimateDialogOverlay,
DialogClose,
DialogTrigger,
DialogContent as AnimateDialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@/components/animate-ui/primitives/radix/dialog';
// Drop-in DialogContent that bundles portal + overlay + close button
// while delegating animation to animate-ui's spring-based dialog
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
HTMLDivElement,
React.ComponentProps<typeof AnimateDialogContent>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
<AnimateDialogOverlay className="fixed inset-0 z-50 bg-black/80" />
<AnimateDialogContent
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<DialogClose className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogClose>
</AnimateDialogContent>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
));
DialogContent.displayName = 'DialogContent';
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
const DialogOverlay = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof AnimateDialogOverlay>
>((props, ref) => (
<AnimateDialogOverlay ref={ref} {...props} />
));
DialogOverlay.displayName = 'DialogOverlay';
export {
Dialog,
@@ -119,4 +62,4 @@ export {
DialogFooter,
DialogTitle,
DialogDescription,
}
};
+27 -23
View File
@@ -1,29 +1,33 @@
"use client"
'use client';
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import * as React from 'react';
import { cn } from '@/lib/utils';
import { cn } from "@/lib/utils"
import {
Switch as AnimateSwitch,
SwitchThumb,
type SwitchProps as AnimateSwitchProps,
} from '@/components/animate-ui/primitives/radix/switch';
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
type SwitchProps = Omit<AnimateSwitchProps, 'children'>;
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
({ className, ...props }, ref) => (
<AnimateSwitch
ref={ref}
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
{...props}
>
<SwitchThumb
className="pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0"
pressedAnimation={{ scale: 1.15 }}
/>
</AnimateSwitch>
)
);
Switch.displayName = 'Switch';
export { Switch }
export { Switch };
+28 -24
View File
@@ -1,53 +1,57 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import * as React from 'react';
import { cn } from '@/lib/utils';
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
import {
Tabs,
TabsList as AnimateTabsList,
TabsTrigger as AnimateTabsTrigger,
TabsContent as AnimateTabsContent,
} from '@/components/animate-ui/primitives/radix/tabs';
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
HTMLDivElement,
React.ComponentProps<typeof AnimateTabsList>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
<AnimateTabsList
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
));
TabsList.displayName = 'TabsList';
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
HTMLButtonElement,
React.ComponentProps<typeof AnimateTabsTrigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
<AnimateTabsTrigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
));
TabsTrigger.displayName = 'TabsTrigger';
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
HTMLDivElement,
React.ComponentProps<typeof AnimateTabsContent>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
<AnimateTabsContent
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className
)}
transition={{ duration: 0.2, ease: 'easeOut' }}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
));
TabsContent.displayName = 'TabsContent';
export { Tabs, TabsList, TabsTrigger, TabsContent }
export { Tabs, TabsList, TabsTrigger, TabsContent };
+21 -17
View File
@@ -1,30 +1,34 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
'use client';
import { cn } from "@/lib/utils"
import * as React from 'react';
import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
import {
TooltipProvider,
Tooltip,
TooltipTrigger,
TooltipPortal,
TooltipContent as AnimateTooltipContent,
} from '@/components/animate-ui/primitives/radix/tooltip';
// Bundles the portal so consumers keep the same <TooltipContent> API
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
HTMLDivElement,
React.ComponentProps<typeof AnimateTooltipContent>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
<TooltipPortal>
<AnimateTooltipContent
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground origin-[--radix-tooltip-content-transform-origin]',
className
)}
transition={{ type: 'spring', stiffness: 350, damping: 28 }}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
</TooltipPortal>
));
TooltipContent.displayName = 'TooltipContent';
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };