{
+ return (
+
+
+
+ )
+}
diff --git a/vibn-frontend/components/ui/terminal.tsx b/vibn-frontend/components/ui/terminal.tsx
new file mode 100644
index 0000000..0370980
--- /dev/null
+++ b/vibn-frontend/components/ui/terminal.tsx
@@ -0,0 +1,300 @@
+"use client"
+
+import {
+ Children,
+ createContext,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ComponentType,
+ type RefAttributes,
+} from "react"
+import {
+ motion,
+ useInView,
+ type DOMMotionComponents,
+ type HTMLMotionProps,
+ type MotionProps,
+} from "motion/react"
+
+import { cn } from "@/lib/utils"
+
+interface SequenceContextValue {
+ completeItem: (index: number) => void
+ activeIndex: number
+ sequenceStarted: boolean
+}
+
+const SequenceContext = createContext
(null)
+
+const useSequence = () => useContext(SequenceContext)
+
+const ItemIndexContext = createContext(null)
+const useItemIndex = () => useContext(ItemIndexContext)
+
+const motionElements = {
+ article: motion.article,
+ div: motion.div,
+ h1: motion.h1,
+ h2: motion.h2,
+ h3: motion.h3,
+ h4: motion.h4,
+ h5: motion.h5,
+ h6: motion.h6,
+ li: motion.li,
+ p: motion.p,
+ section: motion.section,
+ span: motion.span,
+} as const
+
+type MotionElementType = Extract<
+ keyof DOMMotionComponents,
+ keyof typeof motionElements
+>
+type TerminalTypingMotionComponent = ComponentType<
+ Omit, "ref"> & RefAttributes
+>
+
+interface AnimatedSpanProps extends MotionProps {
+ children: React.ReactNode
+ delay?: number
+ className?: string
+ startOnView?: boolean
+}
+
+export const AnimatedSpan = ({
+ children,
+ delay = 0,
+ className,
+ startOnView = false,
+ ...props
+}: AnimatedSpanProps) => {
+ const elementRef = useRef(null)
+ const isInView = useInView(elementRef as React.RefObject, {
+ amount: 0.3,
+ once: true,
+ })
+
+ const sequence = useSequence()
+ const itemIndex = useItemIndex()
+ const [hasStarted, setHasStarted] = useState(false)
+ useEffect(() => {
+ if (!sequence || itemIndex === null) return
+ if (!sequence.sequenceStarted) return
+ if (hasStarted) return
+ if (sequence.activeIndex === itemIndex) {
+ setHasStarted(true)
+ }
+ }, [sequence, hasStarted, itemIndex])
+
+ const shouldAnimate = sequence ? hasStarted : startOnView ? isInView : true
+
+ return (
+ {
+ if (!sequence) return
+ if (itemIndex === null) return
+ sequence.completeItem(itemIndex)
+ }}
+ {...props}
+ >
+ {children}
+
+ )
+}
+
+interface TypingAnimationProps extends Omit {
+ children: string
+ className?: string
+ duration?: number
+ delay?: number
+ as?: MotionElementType
+ startOnView?: boolean
+}
+
+export const TypingAnimation = ({
+ children,
+ className,
+ duration = 60,
+ delay = 0,
+ as: Component = "span",
+ startOnView = true,
+ ...props
+}: TypingAnimationProps) => {
+ if (typeof children !== "string") {
+ throw new Error("TypingAnimation: children must be a string. Received:")
+ }
+
+ const MotionComponent = motionElements[
+ Component
+ ] as TerminalTypingMotionComponent
+
+ const [displayedText, setDisplayedText] = useState("")
+ const [started, setStarted] = useState(false)
+ const elementRef = useRef(null)
+ const isInView = useInView(elementRef as React.RefObject, {
+ amount: 0.3,
+ once: true,
+ })
+
+ const sequence = useSequence()
+ const itemIndex = useItemIndex()
+ const hasSequence = sequence !== null
+ const sequenceStarted = sequence?.sequenceStarted ?? false
+ const sequenceActiveIndex = sequence?.activeIndex ?? null
+ const sequenceCompleteItemRef = useRef<
+ SequenceContextValue["completeItem"] | null
+ >(null)
+ const sequenceItemIndexRef = useRef(null)
+
+ useEffect(() => {
+ sequenceCompleteItemRef.current = sequence?.completeItem ?? null
+ sequenceItemIndexRef.current = itemIndex
+ }, [sequence?.completeItem, itemIndex])
+
+ useEffect(() => {
+ let startTimeout: ReturnType | null = null
+
+ if (hasSequence && itemIndex !== null) {
+ if (sequenceStarted && !started && sequenceActiveIndex === itemIndex) {
+ setStarted(true)
+ }
+ } else if (!startOnView || isInView) {
+ startTimeout = setTimeout(() => setStarted(true), delay)
+ }
+
+ return () => {
+ if (startTimeout !== null) {
+ clearTimeout(startTimeout)
+ }
+ }
+ }, [
+ delay,
+ startOnView,
+ isInView,
+ started,
+ hasSequence,
+ sequenceActiveIndex,
+ sequenceStarted,
+ itemIndex,
+ ])
+
+ useEffect(() => {
+ let typingEffect: ReturnType | null = null
+
+ if (started) {
+ let i = 0
+ typingEffect = setInterval(() => {
+ if (i < children.length) {
+ setDisplayedText(children.substring(0, i + 1))
+ i++
+ } else {
+ if (typingEffect !== null) {
+ clearInterval(typingEffect)
+ }
+ const completeItem = sequenceCompleteItemRef.current
+ const currentItemIndex = sequenceItemIndexRef.current
+ if (completeItem && currentItemIndex !== null) {
+ completeItem(currentItemIndex)
+ }
+ }
+ }, duration)
+ }
+
+ return () => {
+ if (typingEffect !== null) {
+ clearInterval(typingEffect)
+ }
+ }
+ }, [children, duration, started])
+
+ return (
+
+ {displayedText}
+
+ )
+}
+
+interface TerminalProps {
+ children: React.ReactNode
+ className?: string
+ sequence?: boolean
+ startOnView?: boolean
+}
+
+export const Terminal = ({
+ children,
+ className,
+ sequence = true,
+ startOnView = true,
+}: TerminalProps) => {
+ const containerRef = useRef(null)
+ const isInView = useInView(containerRef as React.RefObject, {
+ amount: 0.3,
+ once: true,
+ })
+
+ const [activeIndex, setActiveIndex] = useState(0)
+ const sequenceHasStarted = sequence ? !startOnView || isInView : false
+
+ const contextValue = useMemo(() => {
+ if (!sequence) return null
+ return {
+ completeItem: (index: number) => {
+ setActiveIndex((current) => (index === current ? current + 1 : current))
+ },
+ activeIndex,
+ sequenceStarted: sequenceHasStarted,
+ }
+ }, [sequence, activeIndex, sequenceHasStarted])
+
+ const wrappedChildren = useMemo(() => {
+ if (!sequence) return children
+ const array = Children.toArray(children)
+ return array.map((child, index) => (
+
+ {child as React.ReactNode}
+
+ ))
+ }, [children, sequence])
+
+ const content = (
+
+
+
+ {wrappedChildren}
+
+
+ )
+
+ if (!sequence) return content
+
+ return (
+
+ {content}
+
+ )
+}