Skip to content

Commit 3753824

Browse files
feat: add sublte animations for the blog/blog details pages
1 parent 32b6078 commit 3753824

File tree

10 files changed

+513
-259
lines changed

10 files changed

+513
-259
lines changed
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
'use client'
2+
3+
import { motion, useReducedMotion } from 'framer-motion'
4+
5+
const EASE_OUT_QUINT = [0.23, 1, 0.32, 1] as const
6+
const STAGGER = 0.15
7+
const DURATION = 0.35
8+
const Y_OFFSET = 10
9+
10+
const containerVariants = {
11+
hidden: {},
12+
visible: {
13+
transition: { staggerChildren: STAGGER },
14+
},
15+
}
16+
17+
const itemVariants = {
18+
hidden: { opacity: 0, y: Y_OFFSET },
19+
visible: {
20+
opacity: 1,
21+
y: 0,
22+
transition: { duration: DURATION, ease: EASE_OUT_QUINT },
23+
},
24+
}
25+
26+
export function ArticleHeaderMotion({ children }: { children: React.ReactNode }) {
27+
const shouldReduceMotion = useReducedMotion()
28+
29+
return (
30+
<motion.div
31+
variants={containerVariants}
32+
initial={shouldReduceMotion ? 'visible' : 'hidden'}
33+
animate='visible'
34+
>
35+
{children}
36+
</motion.div>
37+
)
38+
}
39+
40+
export function ArticleHeaderItem({
41+
children,
42+
className,
43+
}: {
44+
children: React.ReactNode
45+
className?: string
46+
}) {
47+
return (
48+
<motion.div variants={itemVariants} className={className}>
49+
{children}
50+
</motion.div>
51+
)
52+
}

apps/sim/app/(landing)/studio/[slug]/page.tsx

Lines changed: 57 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ import {
1010
AnimatedColorBlocks,
1111
AnimatedColorBlocksVertical,
1212
} from '@/app/(landing)/studio/[slug]/animated-blocks'
13+
import {
14+
ArticleHeaderItem,
15+
ArticleHeaderMotion,
16+
} from '@/app/(landing)/studio/[slug]/article-header'
1317
import { ArticleSidebar } from '@/app/(landing)/studio/[slug]/article-sidebar'
1418
import { ShareButtons } from '@/app/(landing)/studio/[slug]/share-button'
1519
import { getPrimaryCategory, getTagCategory } from '@/app/(landing)/studio/tag-colors'
@@ -75,48 +79,59 @@ export default async function Page({ params }: { params: Promise<{ slug: string
7579
<AnimatedColorBlocksVertical />
7680
</div>
7781
</div>
78-
<div className='mb-6 flex items-center gap-3'>
79-
<span
80-
className='inline-block h-3 w-3'
81-
style={{ backgroundColor: categoryColor }}
82-
aria-hidden='true'
83-
/>
84-
<div className='font-mono text-[11px] uppercase tracking-widest text-[#999]'>
85-
<time dateTime={post.date} itemProp='datePublished'>
86-
{new Date(post.date).toLocaleDateString('en-US', {
87-
month: 'short',
88-
day: '2-digit',
89-
year: 'numeric',
90-
})}
91-
</time>
92-
{' // '}
93-
<span style={{ color: categoryColor }}>{category.label}</span>
94-
</div>
95-
</div>
96-
<h1
97-
className='mb-6 font-[500] text-[36px] leading-[1.15] tracking-tight text-[#ECECEC] sm:text-[40px] md:text-[48px]'
98-
itemProp='headline'
99-
>
100-
{post.title}
101-
</h1>
102-
<p className='mb-6 text-[18px] leading-relaxed text-[#999]' itemProp='description'>
103-
{post.description}
104-
</p>
105-
{post.tags.length > 0 && (
106-
<div className='flex flex-wrap items-center gap-x-1.5 gap-y-1 font-mono text-[11px] text-[#666]'>
107-
{post.tags.map((tag, i) => (
108-
<span key={tag}>
109-
<Link
110-
href={`/studio?tag=${encodeURIComponent(getTagCategory(tag))}`}
111-
className='transition-colors hover:text-[#999]'
112-
>
113-
{tag}
114-
</Link>
115-
{i < post.tags.length - 1 && <span className='ml-1.5 text-[#3d3d3d]'>/</span>}
116-
</span>
117-
))}
118-
</div>
119-
)}
82+
<ArticleHeaderMotion>
83+
<ArticleHeaderItem className='mb-6 flex items-center gap-3'>
84+
<span
85+
className='inline-block h-3 w-3'
86+
style={{ backgroundColor: categoryColor }}
87+
aria-hidden='true'
88+
/>
89+
<div className='font-mono text-[11px] uppercase tracking-widest text-[#999]'>
90+
<time dateTime={post.date} itemProp='datePublished'>
91+
{new Date(post.date).toLocaleDateString('en-US', {
92+
month: 'short',
93+
day: '2-digit',
94+
year: 'numeric',
95+
})}
96+
</time>
97+
{' // '}
98+
<span style={{ color: categoryColor }}>{category.label}</span>
99+
</div>
100+
</ArticleHeaderItem>
101+
<ArticleHeaderItem>
102+
<h1
103+
className='mb-6 font-[500] text-[36px] leading-[1.15] tracking-tight text-[#ECECEC] sm:text-[40px] md:text-[48px]'
104+
itemProp='headline'
105+
>
106+
{post.title}
107+
</h1>
108+
</ArticleHeaderItem>
109+
<ArticleHeaderItem>
110+
<p className='mb-6 text-[18px] leading-relaxed text-[#999]' itemProp='description'>
111+
{post.description}
112+
</p>
113+
</ArticleHeaderItem>
114+
115+
{post.tags.length > 0 && (
116+
<ArticleHeaderItem>
117+
<div className='flex flex-wrap items-center gap-x-1.5 gap-y-1 font-mono text-[11px] text-[#666]'>
118+
{post.tags.map((tag, i) => (
119+
<span key={tag}>
120+
<Link
121+
href={`/studio?tag=${encodeURIComponent(getTagCategory(tag))}`}
122+
className='transition-colors hover:text-[#999]'
123+
>
124+
{tag}
125+
</Link>
126+
{i < post.tags.length - 1 && (
127+
<span className='ml-1.5 text-[#3d3d3d]'>/</span>
128+
)}
129+
</span>
130+
))}
131+
</div>
132+
</ArticleHeaderItem>
133+
)}
134+
</ArticleHeaderMotion>
120135

121136
<meta itemProp='dateModified' content={post.updated ?? post.date} />
122137
</header>

apps/sim/app/(landing)/studio/[slug]/share-button.tsx

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
'use client'
22

33
import { useState } from 'react'
4-
import { Check, Linkedin, Link2, Twitter } from 'lucide-react'
4+
import { AnimatePresence, motion } from 'framer-motion'
5+
import { Check, Link2, Linkedin, Twitter } from 'lucide-react'
56

67
interface ShareButtonsProps {
78
url: string
@@ -32,7 +33,7 @@ export function ShareButtons({ url, title }: ShareButtonsProps) {
3233
}
3334

3435
const btnClass =
35-
'flex h-10 w-10 items-center justify-center rounded-[5px] border border-[#2A2A2A] bg-[#232323] text-[#999] transition-all hover:border-[#2ABBF8] hover:text-[#2ABBF8]'
36+
'flex h-10 w-10 items-center justify-center rounded-[5px] border border-[#2A2A2A] bg-[#232323] text-[#999] transition-[color,border-color] duration-150 ease [@media(hover:hover)]:hover:border-[#2ABBF8] [@media(hover:hover)]:hover:text-[#2ABBF8] active:scale-[0.95]'
3637

3738
return (
3839
<div className='flex gap-2'>
@@ -58,11 +59,31 @@ export function ShareButtons({ url, title }: ShareButtonsProps) {
5859
className={btnClass}
5960
aria-label={copied ? 'Link copied' : 'Copy link'}
6061
>
61-
{copied ? (
62-
<Check className='h-4 w-4 text-[#00F701]' aria-hidden='true' />
63-
) : (
64-
<Link2 className='h-4 w-4' aria-hidden='true' />
65-
)}
62+
<AnimatePresence mode='wait'>
63+
{copied ? (
64+
<motion.span
65+
key='check'
66+
initial={{ opacity: 0, scale: 0.5 }}
67+
animate={{ opacity: 1, scale: 1 }}
68+
exit={{ opacity: 0, scale: 0.5 }}
69+
transition={{ duration: 0.15, ease: 'easeOut' }}
70+
className='flex items-center justify-center'
71+
>
72+
<Check className='h-4 w-4 text-[#00F701]' aria-hidden='true' />
73+
</motion.span>
74+
) : (
75+
<motion.span
76+
key='link'
77+
initial={{ opacity: 0, scale: 0.5 }}
78+
animate={{ opacity: 1, scale: 1 }}
79+
exit={{ opacity: 0, scale: 0.5 }}
80+
transition={{ duration: 0.15, ease: 'easeOut' }}
81+
className='flex items-center justify-center'
82+
>
83+
<Link2 className='h-4 w-4' aria-hidden='true' />
84+
</motion.span>
85+
)}
86+
</AnimatePresence>
6687
</button>
6788
</div>
6889
)

apps/sim/app/(landing)/studio/[slug]/table-of-contents.tsx

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
'use client'
22

3-
import { List } from 'lucide-react'
43
import { useCallback, useEffect, useRef, useState } from 'react'
4+
import { motion, useReducedMotion } from 'framer-motion'
5+
import { List } from 'lucide-react'
56

67
interface Heading {
78
text: string
@@ -13,15 +14,19 @@ interface TableOfContentsProps {
1314
}
1415

1516
export function TableOfContents({ headings }: TableOfContentsProps) {
16-
const [activeId, setActiveId] = useState<string>('')
17+
const [activeId, setActiveId] = useState<string>(headings[0]?.id ?? '')
1718
const observerRef = useRef<IntersectionObserver | null>(null)
19+
const isClickScrolling = useRef(false)
20+
const shouldReduceMotion = useReducedMotion()
1821

1922
const setupObserver = useCallback(() => {
2023
if (observerRef.current) {
2124
observerRef.current.disconnect()
2225
}
2326

2427
const callback: IntersectionObserverCallback = (entries) => {
28+
if (isClickScrolling.current) return
29+
2530
const visible = entries
2631
.filter((e) => e.isIntersecting)
2732
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
@@ -47,18 +52,25 @@ export function TableOfContents({ headings }: TableOfContentsProps) {
4752
return () => observerRef.current?.disconnect()
4853
}, [setupObserver])
4954

50-
const currentId = activeId || headings[0]?.id || ''
51-
5255
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
5356
e.preventDefault()
5457
const el = document.getElementById(id)
5558
if (!el) return
5659

60+
isClickScrolling.current = true
61+
setActiveId(id)
62+
5763
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
5864
el.scrollIntoView({ behavior: prefersReducedMotion ? 'auto' : 'smooth' })
5965

6066
window.history.replaceState(null, '', `#${id}`)
61-
setActiveId(id)
67+
68+
setTimeout(
69+
() => {
70+
isClickScrolling.current = false
71+
},
72+
prefersReducedMotion ? 50 : 800
73+
)
6274
}
6375

6476
if (headings.length === 0) return null
@@ -69,23 +81,33 @@ export function TableOfContents({ headings }: TableOfContentsProps) {
6981
<List className='h-3 w-3 text-[#2ABBF8]' aria-hidden />
7082
Contents
7183
</div>
72-
<nav className='flex flex-col space-y-2 font-mono text-[12px] font-medium text-[#999]'>
84+
<nav className='relative flex flex-col space-y-1 font-mono text-[12px] font-medium text-[#999]'>
7385
{headings.map((h, idx) => {
74-
const isActive = currentId === h.id
86+
const isActive = activeId === h.id
7587

7688
return (
7789
<a
7890
key={h.id}
7991
href={`#${h.id}`}
8092
onClick={(e) => handleClick(e, h.id)}
81-
className={`flex items-center gap-2 rounded-[5px] px-2 py-1.5 transition-colors ${
82-
isActive
83-
? 'bg-[#2ABBF8]/10 text-[#2ABBF8]'
84-
: 'hover:bg-[#2A2A2A]/50 hover:text-[#ECECEC]'
85-
}`}
93+
className='relative flex items-center gap-2 rounded-[5px] px-2 py-1.5 transition-colors hover:text-[#ECECEC]'
94+
style={{ color: isActive ? '#2ABBF8' : undefined }}
8695
>
87-
<span className='text-[10px] opacity-50'>{String(idx + 1).padStart(2, '0')}</span>
88-
{h.text}
96+
{isActive && (
97+
<motion.span
98+
layoutId='toc-highlight'
99+
className='absolute inset-0 rounded-[5px] bg-[#2ABBF8]/10'
100+
transition={
101+
shouldReduceMotion
102+
? { duration: 0 }
103+
: { type: 'spring', duration: 0.25, bounce: 0 }
104+
}
105+
/>
106+
)}
107+
<span className='relative z-10 text-[10px] opacity-50'>
108+
{String(idx + 1).padStart(2, '0')}
109+
</span>
110+
<span className='relative z-10'>{h.text}</span>
89111
</a>
90112
)
91113
})}

0 commit comments

Comments
 (0)