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
@@ -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 };