Skip to content

Commit 383194a

Browse files
committed
feat(website): add morphing text to hero and video dialog to tutorial
1 parent 0a8d56a commit 383194a

4 files changed

Lines changed: 322 additions & 16 deletions

File tree

website/src/app/tutorial/page.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Meteors } from "@/components/ui/meteors";
99
import { TextAnimate } from "@/components/ui/text-animate";
1010
import { Highlighter } from "@/components/ui/highlighter";
1111
import { BentoGrid, BentoCard } from "@/components/ui/bento-grid";
12+
import { HeroVideoDialog } from "@/components/ui/hero-video-dialog";
1213
import { ShineBorder } from "@/components/ui/shine-border";
1314
import { cn } from "@/lib/utils";
1415

@@ -139,6 +140,20 @@ export default function TutorialPage() {
139140
</BentoGrid>
140141
</div>
141142

143+
<div className="mb-20">
144+
<h2 className="mb-6 text-xl font-bold text-slate-900 flex items-center gap-2">
145+
<PlayCircle className="h-5 w-5 text-slate-700" />
146+
Video Tutorial
147+
</h2>
148+
<HeroVideoDialog
149+
animationStyle="from-center"
150+
videoSrc="https://www.youtube.com/embed/dQw4w9WgXcQ" // TODO: Replace with actual tutorial video
151+
thumbnailSrc="https://startup-template-sage.vercel.app/hero-light.png" // Placeholder thumbnail
152+
thumbnailAlt="TunePort Tutorial Walkthrough"
153+
className="w-full rounded-xl border border-slate-200 shadow-sm"
154+
/>
155+
</div>
156+
142157
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-8 md:p-10">
143158
<h2 className="mb-6 text-xl font-bold text-slate-900 flex items-center gap-2" data-animate="text" data-animate-variant="slide-left">
144159
<Settings className="h-5 w-5 text-slate-700" />

website/src/components/sections/hero.tsx

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { BorderBeam } from "@/components/ui/border-beam";
88
import { Pointer } from "@/components/ui/pointer";
99
import { ShimmerButton } from "@/components/ui/shimmer-button";
1010
import { Button } from "@/components/ui/button";
11+
import { MorphingText } from "@/components/ui/morphing-text";
1112
import { TextAnimate } from "@/components/ui/text-animate";
1213

1314
import { AnimatedGradientText } from "@/components/ui/animated-gradient-text";
@@ -65,22 +66,12 @@ export function Hero() {
6566
</AnimatedGradientText>
6667
</div>
6768

68-
<h1 className="text-4xl font-bold tracking-tighter text-slate-900 sm:text-6xl md:text-7xl lg:text-8xl text-balance break-keep hyphens-none">
69-
<TextAnimate
70-
animation="blurInUp"
71-
by="text"
72-
className="text-inherit hidden sm:block"
73-
>
74-
Sync. Download. Disappear.
75-
</TextAnimate>
76-
<TextAnimate
77-
animation="blurInUp"
78-
by="text"
79-
className="text-inherit sm:hidden"
80-
>
81-
{"Sync. Download.\nDisappear."}
82-
</TextAnimate>
83-
</h1>
69+
<div className="my-8">
70+
<MorphingText
71+
texts={["Sync.", "Download.", "Disappear."]}
72+
className="text-slate-900"
73+
/>
74+
</div>
8475

8576
<TextAnimate
8677
animation="fadeIn"
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/* eslint-disable @next/next/no-img-element */
2+
"use client"
3+
4+
import { useState } from "react"
5+
import { Play, XIcon } from "lucide-react"
6+
import { AnimatePresence, motion } from "motion/react"
7+
8+
import { cn } from "@/lib/utils"
9+
10+
type AnimationStyle =
11+
| "from-bottom"
12+
| "from-center"
13+
| "from-top"
14+
| "from-left"
15+
| "from-right"
16+
| "fade"
17+
| "top-in-bottom-out"
18+
| "left-in-right-out"
19+
20+
interface HeroVideoProps {
21+
animationStyle?: AnimationStyle
22+
videoSrc: string
23+
thumbnailSrc: string
24+
thumbnailAlt?: string
25+
className?: string
26+
}
27+
28+
const animationVariants = {
29+
"from-bottom": {
30+
initial: { y: "100%", opacity: 0 },
31+
animate: { y: 0, opacity: 1 },
32+
exit: { y: "100%", opacity: 0 },
33+
},
34+
"from-center": {
35+
initial: { scale: 0.5, opacity: 0 },
36+
animate: { scale: 1, opacity: 1 },
37+
exit: { scale: 0.5, opacity: 0 },
38+
},
39+
"from-top": {
40+
initial: { y: "-100%", opacity: 0 },
41+
animate: { y: 0, opacity: 1 },
42+
exit: { y: "-100%", opacity: 0 },
43+
},
44+
"from-left": {
45+
initial: { x: "-100%", opacity: 0 },
46+
animate: { x: 0, opacity: 1 },
47+
exit: { x: "-100%", opacity: 0 },
48+
},
49+
"from-right": {
50+
initial: { x: "100%", opacity: 0 },
51+
animate: { x: 0, opacity: 1 },
52+
exit: { x: "100%", opacity: 0 },
53+
},
54+
fade: {
55+
initial: { opacity: 0 },
56+
animate: { opacity: 1 },
57+
exit: { opacity: 0 },
58+
},
59+
"top-in-bottom-out": {
60+
initial: { y: "-100%", opacity: 0 },
61+
animate: { y: 0, opacity: 1 },
62+
exit: { y: "100%", opacity: 0 },
63+
},
64+
"left-in-right-out": {
65+
initial: { x: "-100%", opacity: 0 },
66+
animate: { x: 0, opacity: 1 },
67+
exit: { x: "100%", opacity: 0 },
68+
},
69+
}
70+
71+
export function HeroVideoDialog({
72+
animationStyle = "from-center",
73+
videoSrc,
74+
thumbnailSrc,
75+
thumbnailAlt = "Video thumbnail",
76+
className,
77+
}: HeroVideoProps) {
78+
const [isVideoOpen, setIsVideoOpen] = useState(false)
79+
const selectedAnimation = animationVariants[animationStyle]
80+
81+
return (
82+
<div className={cn("relative", className)}>
83+
<button
84+
type="button"
85+
aria-label="Play video"
86+
className="group relative cursor-pointer border-0 bg-transparent p-0 w-full"
87+
onClick={() => setIsVideoOpen(true)}
88+
>
89+
<img
90+
src={thumbnailSrc}
91+
alt={thumbnailAlt}
92+
width={1920}
93+
height={1080}
94+
className="w-full rounded-xl border shadow-lg transition-all duration-200 ease-out group-hover:brightness-[0.8] object-cover aspect-video"
95+
/>
96+
<div className="absolute inset-0 flex scale-[0.9] items-center justify-center rounded-2xl transition-all duration-200 ease-out group-hover:scale-100">
97+
<div className="bg-primary/10 flex size-28 items-center justify-center rounded-full backdrop-blur-md">
98+
<div
99+
className={`from-primary/30 to-primary relative flex size-20 scale-100 items-center justify-center rounded-full bg-gradient-to-b shadow-md transition-all duration-200 ease-out group-hover:scale-[1.2]`}
100+
>
101+
<Play
102+
className="size-8 scale-100 fill-white text-white transition-transform duration-200 ease-out group-hover:scale-105"
103+
style={{
104+
filter:
105+
"drop-shadow(0 4px 3px rgb(0 0 0 / 0.07)) drop-shadow(0 2px 2px rgb(0 0 0 / 0.06))",
106+
}}
107+
/>
108+
</div>
109+
</div>
110+
</div>
111+
</button>
112+
<AnimatePresence>
113+
{isVideoOpen && (
114+
<motion.div
115+
initial={{ opacity: 0 }}
116+
animate={{ opacity: 1 }}
117+
role="button"
118+
tabIndex={0}
119+
onKeyDown={(e) => {
120+
if (e.key === "Escape" || e.key === "Enter" || e.key === " ") {
121+
setIsVideoOpen(false)
122+
}
123+
}}
124+
onClick={() => setIsVideoOpen(false)}
125+
exit={{ opacity: 0 }}
126+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-md p-4"
127+
>
128+
<motion.div
129+
{...selectedAnimation}
130+
transition={{ type: "spring", damping: 30, stiffness: 300 }}
131+
className="relative aspect-video w-full max-w-4xl"
132+
>
133+
<motion.button className="absolute -top-16 right-0 rounded-full bg-neutral-900/50 p-2 text-xl text-white ring-1 backdrop-blur-md dark:bg-neutral-100/50 dark:text-black">
134+
<XIcon className="size-5" />
135+
</motion.button>
136+
<div className="relative isolate z-[1] size-full overflow-hidden rounded-2xl border-2 border-white bg-black">
137+
<iframe
138+
src={videoSrc}
139+
title="Hero Video player"
140+
className="size-full rounded-2xl"
141+
allowFullScreen
142+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
143+
></iframe>
144+
</div>
145+
</motion.div>
146+
</motion.div>
147+
)}
148+
</AnimatePresence>
149+
</div>
150+
)
151+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"use client"
2+
3+
import { useCallback, useEffect, useRef } from "react"
4+
5+
import { cn } from "@/lib/utils"
6+
7+
const morphTime = 1.5
8+
const cooldownTime = 0.5
9+
10+
const useMorphingText = (texts: string[]) => {
11+
const textIndexRef = useRef(0)
12+
const morphRef = useRef(0)
13+
const cooldownRef = useRef(0)
14+
const timeRef = useRef(new Date())
15+
16+
const text1Ref = useRef<HTMLSpanElement>(null)
17+
const text2Ref = useRef<HTMLSpanElement>(null)
18+
19+
const setStyles = useCallback(
20+
(fraction: number) => {
21+
const [current1, current2] = [text1Ref.current, text2Ref.current]
22+
if (!current1 || !current2) return
23+
24+
current2.style.filter = `blur(${Math.min(8 / fraction - 8, 100)}px)`
25+
current2.style.opacity = `${Math.pow(fraction, 0.4) * 100}%`
26+
27+
const invertedFraction = 1 - fraction
28+
current1.style.filter = `blur(${Math.min(
29+
8 / invertedFraction - 8,
30+
100
31+
)}px)`
32+
current1.style.opacity = `${Math.pow(invertedFraction, 0.4) * 100}%`
33+
34+
current1.textContent = texts[textIndexRef.current % texts.length]
35+
current2.textContent = texts[(textIndexRef.current + 1) % texts.length]
36+
},
37+
[texts]
38+
)
39+
40+
const doMorph = useCallback(() => {
41+
morphRef.current -= cooldownRef.current
42+
cooldownRef.current = 0
43+
44+
let fraction = morphRef.current / morphTime
45+
46+
if (fraction > 1) {
47+
cooldownRef.current = cooldownTime
48+
fraction = 1
49+
}
50+
51+
setStyles(fraction)
52+
53+
if (fraction === 1) {
54+
textIndexRef.current++
55+
}
56+
}, [setStyles])
57+
58+
const doCooldown = useCallback(() => {
59+
morphRef.current = 0
60+
const [current1, current2] = [text1Ref.current, text2Ref.current]
61+
if (current1 && current2) {
62+
current2.style.filter = "none"
63+
current2.style.opacity = "100%"
64+
current1.style.filter = "none"
65+
current1.style.opacity = "0%"
66+
}
67+
}, [])
68+
69+
useEffect(() => {
70+
let animationFrameId: number
71+
72+
const animate = () => {
73+
animationFrameId = requestAnimationFrame(animate)
74+
75+
const newTime = new Date()
76+
const dt = (newTime.getTime() - timeRef.current.getTime()) / 1000
77+
timeRef.current = newTime
78+
79+
cooldownRef.current -= dt
80+
81+
if (cooldownRef.current <= 0) doMorph()
82+
else doCooldown()
83+
}
84+
85+
animate()
86+
return () => {
87+
cancelAnimationFrame(animationFrameId)
88+
}
89+
}, [doMorph, doCooldown])
90+
91+
return { text1Ref, text2Ref }
92+
}
93+
94+
interface MorphingTextProps {
95+
className?: string
96+
texts: string[]
97+
}
98+
99+
const Texts: React.FC<Pick<MorphingTextProps, "texts">> = ({ texts }) => {
100+
const { text1Ref, text2Ref } = useMorphingText(texts)
101+
return (
102+
<>
103+
<span
104+
className="absolute inset-x-0 top-0 m-auto inline-block w-full"
105+
ref={text1Ref}
106+
/>
107+
<span
108+
className="absolute inset-x-0 top-0 m-auto inline-block w-full"
109+
ref={text2Ref}
110+
/>
111+
</>
112+
)
113+
}
114+
115+
const SvgFilters: React.FC = () => (
116+
<svg
117+
id="filters"
118+
className="fixed h-0 w-0"
119+
preserveAspectRatio="xMidYMid slice"
120+
>
121+
<defs>
122+
<filter id="threshold">
123+
<feColorMatrix
124+
in="SourceGraphic"
125+
type="matrix"
126+
values="1 0 0 0 0
127+
0 1 0 0 0
128+
0 0 1 0 0
129+
0 0 0 255 -140"
130+
/>
131+
</filter>
132+
</defs>
133+
</svg>
134+
)
135+
136+
export const MorphingText: React.FC<MorphingTextProps> = ({
137+
texts,
138+
className,
139+
}) => (
140+
<div
141+
className={cn(
142+
"relative mx-auto h-16 w-full max-w-screen-md text-center font-sans text-[40pt] leading-none font-bold [filter:url(#threshold)_blur(0.6px)] md:h-24 lg:text-[6rem]",
143+
className
144+
)}
145+
>
146+
<Texts texts={texts} />
147+
<SvgFilters />
148+
</div>
149+
)

0 commit comments

Comments
 (0)