diff options
| author | alexf37 <[email protected]> | 2025-10-01 21:59:53 +0000 |
|---|---|---|
| committer | alexf37 <[email protected]> | 2025-10-01 21:59:53 +0000 |
| commit | ede0f393030e1006fa5463394e7d1219ca74e1f3 (patch) | |
| tree | dde233d1d7359b74c258037acd7251a67e01222e /apps/web/components | |
| parent | feat: Claude memory integration (diff) | |
| download | archived-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.tsx | 294 | ||||
| -rw-r--r-- | apps/web/components/text-morph.tsx | 74 |
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> + ); +} |