aboutsummaryrefslogtreecommitdiff
path: root/apps/web/components
diff options
context:
space:
mode:
authoralexf37 <[email protected]>2025-10-01 21:59:53 +0000
committeralexf37 <[email protected]>2025-10-01 21:59:53 +0000
commitede0f393030e1006fa5463394e7d1219ca74e1f3 (patch)
treedde233d1d7359b74c258037acd7251a67e01222e /apps/web/components
parentfeat: Claude memory integration (diff)
downloadarchived-supermemory-ede0f393030e1006fa5463394e7d1219ca74e1f3.tar.xz
archived-supermemory-ede0f393030e1006fa5463394e7d1219ca74e1f3.zip
feat: new onboarding flow (#408)09-03-feat_new_onboarding_flow
This is the onboarding flow that you have all seen in my demo.
Diffstat (limited to 'apps/web/components')
-rw-r--r--apps/web/components/text-effect.tsx294
-rw-r--r--apps/web/components/text-morph.tsx74
2 files changed, 368 insertions, 0 deletions
diff --git a/apps/web/components/text-effect.tsx b/apps/web/components/text-effect.tsx
new file mode 100644
index 00000000..82c6823c
--- /dev/null
+++ b/apps/web/components/text-effect.tsx
@@ -0,0 +1,294 @@
+'use client';
+import { cn } from '@lib/utils';
+import {
+ AnimatePresence,
+ motion
+} from 'motion/react';
+import type {
+ TargetAndTransition,
+ Transition,
+ Variant,
+ Variants,
+} from 'motion/react'
+import React from 'react';
+
+export type PresetType = 'blur' | 'fade-in-blur' | 'scale' | 'fade' | 'slide';
+
+export type PerType = 'word' | 'char' | 'line';
+
+export type TextEffectProps = {
+ children: string;
+ per?: PerType;
+ as?: keyof React.JSX.IntrinsicElements;
+ variants?: {
+ container?: Variants;
+ item?: Variants;
+ };
+ className?: string;
+ preset?: PresetType;
+ delay?: number;
+ speedReveal?: number;
+ speedSegment?: number;
+ trigger?: boolean;
+ onAnimationComplete?: () => void;
+ onAnimationStart?: () => void;
+ segmentWrapperClassName?: string;
+ containerTransition?: Transition;
+ segmentTransition?: Transition;
+ style?: React.CSSProperties;
+};
+
+const defaultStaggerTimes: Record<PerType, number> = {
+ char: 0.03,
+ word: 0.05,
+ line: 0.1,
+};
+
+const defaultContainerVariants: Variants = {
+ hidden: { opacity: 0 },
+ visible: {
+ opacity: 1,
+ transition: {
+ staggerChildren: 0.05,
+ },
+ },
+ exit: {
+ transition: { staggerChildren: 0.05, staggerDirection: -1 },
+ },
+};
+
+const defaultItemVariants: Variants = {
+ hidden: { opacity: 0 },
+ visible: {
+ opacity: 1,
+ },
+ exit: { opacity: 0 },
+};
+
+const presetVariants: Record<
+ PresetType,
+ { container: Variants; item: Variants }
+> = {
+ blur: {
+ container: defaultContainerVariants,
+ item: {
+ hidden: { opacity: 0, filter: 'blur(12px)' },
+ visible: { opacity: 1, filter: 'blur(0px)' },
+ exit: { opacity: 0, filter: 'blur(12px)' },
+ },
+ },
+ 'fade-in-blur': {
+ container: defaultContainerVariants,
+ item: {
+ hidden: { opacity: 0, y: 20, filter: 'blur(12px)' },
+ visible: { opacity: 1, y: 0, filter: 'blur(0px)' },
+ exit: { opacity: 0, y: 20, filter: 'blur(12px)' },
+ },
+ },
+ scale: {
+ container: defaultContainerVariants,
+ item: {
+ hidden: { opacity: 0, scale: 0 },
+ visible: { opacity: 1, scale: 1 },
+ exit: { opacity: 0, scale: 0 },
+ },
+ },
+ fade: {
+ container: defaultContainerVariants,
+ item: {
+ hidden: { opacity: 0 },
+ visible: { opacity: 1 },
+ exit: { opacity: 0 },
+ },
+ },
+ slide: {
+ container: defaultContainerVariants,
+ item: {
+ hidden: { opacity: 0, y: 20 },
+ visible: { opacity: 1, y: 0 },
+ exit: { opacity: 0, y: 20 },
+ },
+ },
+};
+
+const AnimationComponent: React.FC<{
+ segment: string;
+ variants: Variants;
+ per: 'line' | 'word' | 'char';
+ segmentWrapperClassName?: string;
+}> = React.memo(({ segment, variants, per, segmentWrapperClassName }) => {
+ const content =
+ per === 'line' ? (
+ <motion.span variants={variants} className='block'>
+ {segment}
+ </motion.span>
+ ) : per === 'word' ? (
+ <motion.span
+ aria-hidden='true'
+ variants={variants}
+ className='inline-block whitespace-pre'
+ >
+ {segment}
+ </motion.span>
+ ) : (
+ <motion.span className='inline-block whitespace-pre'>
+ {segment.split('').map((char, charIndex) => (
+ <motion.span
+ key={`char-${charIndex}`}
+ aria-hidden='true'
+ variants={variants}
+ className='inline-block whitespace-pre'
+ >
+ {char}
+ </motion.span>
+ ))}
+ </motion.span>
+ );
+
+ if (!segmentWrapperClassName) {
+ return content;
+ }
+
+ const defaultWrapperClassName = per === 'line' ? 'block' : 'inline-block';
+
+ return (
+ <span className={cn(defaultWrapperClassName, segmentWrapperClassName)}>
+ {content}
+ </span>
+ );
+});
+
+AnimationComponent.displayName = 'AnimationComponent';
+
+const splitText = (text: string, per: PerType) => {
+ if (per === 'line') return text.split('\n');
+ return text.split(/(\s+)/);
+};
+
+const hasTransition = (
+ variant?: Variant
+): variant is TargetAndTransition & { transition?: Transition } => {
+ if (!variant) return false;
+ return (
+ typeof variant === 'object' && 'transition' in variant
+ );
+};
+
+const createVariantsWithTransition = (
+ baseVariants: Variants,
+ transition?: Transition & { exit?: Transition }
+): Variants => {
+ if (!transition) return baseVariants;
+
+ const { exit: _, ...mainTransition } = transition;
+
+ return {
+ ...baseVariants,
+ visible: {
+ ...baseVariants.visible,
+ transition: {
+ ...(hasTransition(baseVariants.visible)
+ ? baseVariants.visible.transition
+ : {}),
+ ...mainTransition,
+ },
+ },
+ exit: {
+ ...baseVariants.exit,
+ transition: {
+ ...(hasTransition(baseVariants.exit)
+ ? baseVariants.exit.transition
+ : {}),
+ ...mainTransition,
+ staggerDirection: -1,
+ },
+ },
+ };
+};
+
+export function TextEffect({
+ children,
+ per = 'word',
+ as = 'p',
+ variants,
+ className,
+ preset = 'fade',
+ delay = 0,
+ speedReveal = 1,
+ speedSegment = 1,
+ trigger = true,
+ onAnimationComplete,
+ onAnimationStart,
+ segmentWrapperClassName,
+ containerTransition,
+ segmentTransition,
+ style,
+}: TextEffectProps) {
+ const segments = splitText(children, per);
+ const MotionTag = motion[as as keyof typeof motion] as typeof motion.div;
+
+ const baseVariants = preset
+ ? presetVariants[preset]
+ : { container: defaultContainerVariants, item: defaultItemVariants };
+
+ const stagger = defaultStaggerTimes[per] / speedReveal;
+
+ const baseDuration = 0.3 / speedSegment;
+
+ const customStagger = hasTransition(variants?.container?.visible ?? {})
+ ? (variants?.container?.visible as TargetAndTransition).transition
+ ?.staggerChildren
+ : undefined;
+
+ const customDelay = hasTransition(variants?.container?.visible ?? {})
+ ? (variants?.container?.visible as TargetAndTransition).transition
+ ?.delayChildren
+ : undefined;
+
+ const computedVariants = {
+ container: createVariantsWithTransition(
+ variants?.container || baseVariants.container,
+ {
+ staggerChildren: customStagger ?? stagger,
+ delayChildren: customDelay ?? delay,
+ ...containerTransition,
+ exit: {
+ staggerChildren: customStagger ?? stagger,
+ staggerDirection: -1,
+ },
+ }
+ ),
+ item: createVariantsWithTransition(variants?.item || baseVariants.item, {
+ duration: baseDuration,
+ ...segmentTransition,
+ }),
+ };
+
+ return (
+ <AnimatePresence mode='popLayout'>
+ {trigger && (
+ <MotionTag
+ initial='hidden'
+ animate='visible'
+ exit='exit'
+ variants={computedVariants.container}
+ className={className}
+ onAnimationComplete={onAnimationComplete}
+ onAnimationStart={onAnimationStart}
+ style={style}
+ >
+ {per !== 'line' ? <span className='sr-only'>{children}</span> : null}
+ {segments.map((segment, index) => (
+ <AnimationComponent
+ key={`${per}-${index}-${segment}`}
+ segment={segment}
+ variants={computedVariants.item}
+ per={per}
+ segmentWrapperClassName={segmentWrapperClassName}
+ />
+ ))}
+ </MotionTag>
+ )}
+ </AnimatePresence>
+ );
+}
diff --git a/apps/web/components/text-morph.tsx b/apps/web/components/text-morph.tsx
new file mode 100644
index 00000000..467dc999
--- /dev/null
+++ b/apps/web/components/text-morph.tsx
@@ -0,0 +1,74 @@
+'use client';
+import { cn } from '@lib/utils';
+import { AnimatePresence, motion, type Transition, type Variants } from 'motion/react';
+import { useMemo, useId } from 'react';
+
+export type TextMorphProps = {
+ children: string;
+ as?: React.ElementType;
+ className?: string;
+ style?: React.CSSProperties;
+ variants?: Variants;
+ transition?: Transition;
+};
+
+export function TextMorph({
+ children,
+ as: Component = 'p',
+ className,
+ style,
+ variants,
+ transition,
+}: TextMorphProps) {
+ const uniqueId = useId();
+
+ const characters = useMemo(() => {
+ const charCounts: Record<string, number> = {};
+
+ return children.split('').map((char) => {
+ const lowerChar = char.toLowerCase();
+ charCounts[lowerChar] = (charCounts[lowerChar] || 0) + 1;
+
+ return {
+ id: `${uniqueId}-${lowerChar}${charCounts[lowerChar]}`,
+ label: char === ' ' ? '\u00A0' : char,
+ };
+ });
+ }, [children, uniqueId]);
+
+ const defaultVariants: Variants = {
+ initial: { opacity: 0 },
+ animate: { opacity: 1 },
+ exit: { opacity: 0 },
+ };
+
+ const defaultTransition: Transition = {
+ type: 'spring',
+ stiffness: 280,
+ damping: 18,
+ mass: 0.3,
+ };
+
+ return (
+ // @ts-expect-error - style is optional
+ <Component className={cn(className)} aria-label={children} style={style}>
+ <AnimatePresence mode='popLayout' initial={false}>
+ {characters.map((character) => (
+ <motion.span
+ key={character.id}
+ layoutId={character.id}
+ className='inline-block'
+ aria-hidden='true'
+ initial='initial'
+ animate='animate'
+ exit='exit'
+ variants={variants || defaultVariants}
+ transition={transition || defaultTransition}
+ >
+ {character.label}
+ </motion.span>
+ ))}
+ </AnimatePresence>
+ </Component>
+ );
+}