Skip to content

Commit 6a69f30

Browse files
committed
refactor: tune frontend animations and motion polish
Apply Emil Kowalski's design engineering principles across the web app: add press feedback to interactive elements, fix popover transform-origin, strengthen easing curves, cut overlong durations, cap list stagger, and drop Framer Motion overhead from static components.
1 parent 8a5579c commit 6a69f30

20 files changed

Lines changed: 89 additions & 93 deletions

apps/web/src/app/(pages)/(home)/_components/content.tsx

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
'use client';
22

33
import { useQuery } from '@tanstack/react-query';
4-
import { motion } from 'framer-motion';
4+
import { motion } from 'motion/react';
55
import Image from 'next/image';
66
import { useRouter } from 'next/navigation';
77
import { slotStatusQuery } from '../_store/queries/slot-status.query';
88
import { SlotsCounter } from './slots-counter';
99

10+
const STRONG_EASE_OUT = [0.23, 1, 0.32, 1] as const;
11+
1012
export const HomeContent = () => {
1113
const router = useRouter();
1214
const { data: slotStatus } = useQuery(slotStatusQuery());
@@ -25,59 +27,59 @@ export const HomeContent = () => {
2527
<div className='fixed left-0 top-24 flex size-full flex-col items-center justify-start overflow-hidden sm:top-32 md:top-44'>
2628
<motion.h2
2729
className='text-center text-4xl font-black sm:text-6xl md:text-7xl'
28-
initial={{ opacity: 0, y: 20 }}
30+
initial={{ opacity: 0, y: 12 }}
2931
animate={{ opacity: 1, y: 0 }}
30-
transition={{ duration: 0.6 }}
32+
transition={{ duration: 0.25, ease: STRONG_EASE_OUT }}
3133
>
3234
Your Bookmarks, <span className='bg-yellow-200 px-2'>Reimagined</span>
3335
</motion.h2>
3436
<motion.p
3537
className='mt-6 text-balance text-center text-sm text-gray-600 sm:text-xl md:text-2xl'
36-
initial={{ opacity: 0, y: 20 }}
38+
initial={{ opacity: 0, y: 12 }}
3739
animate={{ opacity: 1, y: 0 }}
38-
transition={{ duration: 0.6, delay: 0.2 }}
40+
transition={{ duration: 0.25, delay: 0.05, ease: STRONG_EASE_OUT }}
3941
>
4042
Organize, discover, and access your favorite sites in one place
4143
</motion.p>
4244

4345
<motion.div
4446
className='mt-4'
45-
initial={{ opacity: 0, y: 20 }}
47+
initial={{ opacity: 0, y: 12 }}
4648
animate={{ opacity: 1, y: 0 }}
47-
transition={{ duration: 0.6, delay: 0.25 }}
49+
transition={{ duration: 0.25, delay: 0.1, ease: STRONG_EASE_OUT }}
4850
>
4951
<SlotsCounter />
5052
</motion.div>
5153

5254
<motion.div
5355
className='mt-6 flex gap-4'
54-
initial={{ opacity: 0, y: 20 }}
56+
initial={{ opacity: 0, y: 12 }}
5557
animate={{ opacity: 1, y: 0 }}
56-
transition={{ duration: 0.6, delay: 0.3 }}
58+
transition={{ duration: 0.25, delay: 0.15, ease: STRONG_EASE_OUT }}
5759
>
58-
<motion.button
60+
<button
5961
onClick={handleJoinNowButtonClick}
6062
disabled={!slotStatus?.canSignUp}
61-
className={`h-10 rounded-full px-5 text-sm font-bold transition-colors md:h-12 md:px-8 md:text-lg ${
63+
className={`h-10 rounded-full px-5 text-sm font-bold transition-[transform,background-color] duration-150 ease-out active:scale-[0.97] disabled:active:scale-100 md:h-12 md:px-8 md:text-lg ${
6264
slotStatus?.canSignUp
6365
? 'bg-black text-white hover:bg-gray-800'
6466
: 'cursor-not-allowed bg-gray-400 text-white'
6567
}`}
6668
>
6769
{slotStatus?.canSignUp ? 'Join Now' : 'Slots Full'}
68-
</motion.button>
69-
<motion.button
70+
</button>
71+
<button
7072
onClick={handleGithubButtonClick}
71-
className='h-10 rounded-full border-2 border-black px-5 text-sm font-bold transition-colors hover:bg-gray-100 md:h-12 md:px-8 md:text-lg'
73+
className='h-10 rounded-full border-2 border-black px-5 text-sm font-bold transition-[transform,background-color] duration-150 ease-out hover:bg-gray-100 active:scale-[0.97] md:h-12 md:px-8 md:text-lg'
7274
>
7375
Github
74-
</motion.button>
76+
</button>
7577
</motion.div>
7678

7779
<motion.div
78-
initial={{ opacity: 0, scale: 0.95 }}
80+
initial={{ opacity: 0, scale: 0.97 }}
7981
animate={{ opacity: 1, scale: 1 }}
80-
transition={{ duration: 0.8, delay: 0.5 }}
82+
transition={{ duration: 0.3, delay: 0.2, ease: STRONG_EASE_OUT }}
8183
className='flex justify-center'
8284
>
8385
<Image
Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,41 @@
11
'use client';
22

33
import { useQuery } from '@tanstack/react-query';
4-
import { motion } from 'framer-motion';
54
import { slotStatusQuery } from '../_store/queries/slot-status.query';
65

76
export const SlotsCounter = () => {
87
const { data: slotStatus, isLoading, error } = useQuery(slotStatusQuery());
98

109
if (isLoading) {
1110
return (
12-
<motion.div
13-
className='flex items-center justify-center gap-2 rounded-full bg-gray-100 px-4 py-2'
14-
initial={{ opacity: 0 }}
15-
animate={{ opacity: 1 }}
16-
>
11+
<div className='flex items-center justify-center gap-2 rounded-full bg-gray-100 px-4 py-2'>
1712
<div className='h-2 w-2 animate-pulse rounded-full bg-gray-400' />
1813
<span className='text-sm text-gray-600'>Checking slots...</span>
19-
</motion.div>
14+
</div>
2015
);
2116
}
2217

2318
if (error || !slotStatus) {
2419
return (
25-
<motion.div className='rounded-full bg-red-100 px-4 py-2' initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
20+
<div className='rounded-full bg-red-100 px-4 py-2'>
2621
<span className='text-sm text-red-600'>Unable to check slot status</span>
27-
</motion.div>
22+
</div>
2823
);
2924
}
3025

3126
const isLow = slotStatus.remaining <= 10;
3227
const isFull = slotStatus.remaining === 0;
3328

3429
return (
35-
<motion.div
30+
<div
3631
className={`flex items-center gap-2 rounded-full px-4 py-2 ${
3732
isFull ? 'bg-red-100 text-red-700' : isLow ? 'bg-orange-100 text-orange-700' : 'bg-green-100 text-green-700'
3833
}`}
39-
initial={{ opacity: 0, y: -10 }}
40-
animate={{ opacity: 1, y: 0 }}
41-
transition={{ duration: 0.3 }}
4234
>
4335
<div className={`h-2 w-2 rounded-full ${isFull ? 'bg-red-500' : isLow ? 'bg-orange-500' : 'bg-green-500'}`} />
4436
<span className='text-sm font-medium'>
4537
{slotStatus.remaining} slot{slotStatus.remaining !== 1 ? 's' : ''} left
4638
</span>
47-
</motion.div>
39+
</div>
4840
);
4941
};

apps/web/src/app/(pages)/(home)/home/_components/bookmark-card.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { motion, useAnimation } from 'framer-motion';
1+
import { motion, useAnimation } from 'motion/react';
22
import { Loader2 } from 'lucide-react';
33
import Image from 'next/image';
44
import Link from 'next/link';
@@ -76,7 +76,7 @@ export const BookmarkCard = ({ bookmark, isActive, isBlurred, isViewOnly }: Book
7676
longPressTimer.current = null;
7777
animationControls.start({
7878
scale: 1,
79-
transition: { duration: 0.2 },
79+
transition: { duration: 0.2, ease: [0.23, 1, 0.32, 1] },
8080
});
8181
}
8282
}
@@ -113,7 +113,7 @@ export const BookmarkCard = ({ bookmark, isActive, isBlurred, isViewOnly }: Book
113113
onClick={handleCardClick}
114114
key={bookmark.id}
115115
className={cn(
116-
'flex w-full cursor-pointer items-center gap-3 rounded-md p-2 transition-all hover:bg-muted',
116+
'flex w-full cursor-pointer items-center gap-3 rounded-md p-2 transition-[background-color,filter] duration-150 ease-out hover:bg-muted',
117117
isActive && 'bg-muted',
118118
isBlurred && 'pointer-events-none blur-sm',
119119
)}
@@ -153,7 +153,7 @@ export const BookmarkCard = ({ bookmark, isActive, isBlurred, isViewOnly }: Book
153153
<motion.div
154154
key={bookmark.id}
155155
className={cn(
156-
'flex w-full cursor-pointer select-none items-center gap-3 rounded-md p-2 transition-all sm:hidden',
156+
'flex w-full cursor-pointer select-none items-center gap-3 rounded-md p-2 transition-[background-color] duration-150 ease-out sm:hidden',
157157
isLongPressing && 'bg-muted',
158158
)}
159159
animate={animationControls}
@@ -192,7 +192,7 @@ export const BookmarkCard = ({ bookmark, isActive, isBlurred, isViewOnly }: Book
192192
setIsLongPressing(false);
193193
animationControls.start({
194194
scale: isActive ? 1.05 : 1,
195-
transition: { duration: 0.2 },
195+
transition: { duration: 0.2, ease: [0.23, 1, 0.32, 1] },
196196
});
197197
clearTimeout(longPressTimer.current!);
198198
longPressTimer.current = null;
@@ -221,7 +221,9 @@ const ViewOnlyBookmarkCard = React.memo(({ bookmark }: { bookmark: Bookmark }) =
221221
href={bookmark.url}
222222
target='_blank'
223223
key={bookmark.id}
224-
className={cn('flex w-full cursor-pointer items-center gap-3 rounded-md p-2 transition-all hover:bg-muted')}
224+
className={cn(
225+
'flex w-full cursor-pointer items-center gap-3 rounded-md p-2 transition-[background-color] duration-150 ease-out hover:bg-muted',
226+
)}
225227
animate={animationControls}
226228
onClick={handleSharedBookmarkClick}
227229
>

apps/web/src/app/(pages)/(home)/home/_components/bookmark-list.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export function BookmarkList({ bookmarks: initialBookmarks, isViewOnly }: { book
5151
return (
5252
<div className='relative flex flex-col gap-2'>
5353
{filteredBookmarks?.map((bookmark, index) => (
54-
<BlurFade key={bookmark.id} duration={0.2} delay={0.05 + index * 0.025}>
54+
<BlurFade key={bookmark.id} delay={Math.min(index, 12) * 0.025}>
5555
<BookmarkCard
5656
bookmark={bookmark}
5757
isActive={activeBookmarkId === bookmark.id}

apps/web/src/app/_common/components/add-category-button.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export const AddCategoryButton = () => {
3838
return (
3939
<Sheet open={open} onOpenChange={setOpen}>
4040
<SheetTrigger asChild>
41-
<div className='relative ml-4 flex cursor-pointer items-center justify-center rounded-lg bg-black px-2 py-1 text-white transition-colors hover:bg-black/80'>
41+
<div className='relative ml-4 flex cursor-pointer items-center justify-center rounded-lg bg-black px-2 py-1 text-white transition-[transform,background-color] duration-150 ease-out hover:bg-black/80 active:scale-[0.97]'>
4242
<PlusIcon size={16} />
4343
</div>
4444
</SheetTrigger>

apps/web/src/app/_common/components/animated-tab.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { AnimatePresence, motion } from 'framer-motion';
3+
import { AnimatePresence, motion } from 'motion/react';
44
import { FolderIcon } from 'lucide-react';
55
import { parseAsString, useQueryState } from 'nuqs';
66

@@ -35,7 +35,7 @@ export const AnimatedTab = ({ categories, isShared }: { categories: Category[];
3535
<div className='absolute left-1/2 hidden h-8 -translate-x-1/2 gap-2 bg-background sm:flex'>
3636
{categories.map(category => (
3737
<button
38-
className='relative cursor-pointer rounded-full px-3 py-1.5 text-sm font-medium'
38+
className='relative cursor-pointer rounded-full px-3 py-1.5 text-sm font-medium transition-transform duration-150 ease-out active:scale-[0.97]'
3939
key={category.id}
4040
onClick={() => handleCategoryClick(category)}
4141
style={{
@@ -48,14 +48,14 @@ export const AnimatedTab = ({ categories, isShared }: { categories: Category[];
4848
className='absolute inset-0 bg-black'
4949
layoutId='active-category'
5050
style={{ borderRadius: 8 }}
51-
transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
51+
transition={{ type: 'spring', bounce: 0.15, duration: 0.35 }}
5252
initial={{ opacity: 0 }}
5353
animate={{ opacity: 1 }}
5454
exit={{ opacity: 0 }}
5555
/>
5656
)}
5757
</AnimatePresence>
58-
<p className='relative z-10 max-w-20 truncate text-white mix-blend-exclusion transition-opacity duration-300'>
58+
<p className='relative z-10 max-w-20 truncate text-white mix-blend-exclusion'>
5959
{category.name}
6060
</p>
6161
</button>

apps/web/src/app/_common/components/user-settings-dialog.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { motion } from 'framer-motion';
3+
import { motion } from 'motion/react';
44
import { CheckIcon, Loader2Icon, LoaderCircleIcon, XIcon } from 'lucide-react';
55
import { useRouter } from 'next/navigation';
66
import React from 'react';
@@ -173,10 +173,10 @@ export default function UserSettingsDialog({
173173
onClick={e => {
174174
e.stopPropagation();
175175
}}
176-
initial={{ opacity: 0, scale: 0.95 }}
176+
initial={{ opacity: 0, scale: 0.97 }}
177177
animate={{ opacity: 1, scale: 1 }}
178-
exit={{ opacity: 0, scale: 0.95 }}
179-
transition={{ duration: 0.2, ease: 'easeOut' }}
178+
exit={{ opacity: 0, scale: 0.97 }}
179+
transition={{ duration: 0.2, ease: [0.23, 1, 0.32, 1] }}
180180
>
181181
<DialogHeader className='contents space-y-0 text-left'>
182182
<DialogTitle className='border-b px-6 py-4 text-base'>Edit profile</DialogTitle>

apps/web/src/app/_core/components/animated-underlined-text.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export const AnimatedUnderlinedText = ({ children }: { children: React.ReactNode
55
<span
66
className={cn(
77
'relative flex cursor-pointer px-2 py-1 align-middle hover:text-neutral-800 dark:hover:text-neutral-200',
8-
'underline decoration-transparent underline-offset-2 transition-all duration-300 hover:decoration-current',
8+
'underline decoration-transparent underline-offset-2 transition-[text-decoration-color,color] duration-150 ease-out hover:decoration-current',
99
)}
1010
>
1111
{children}
Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client';
22

33
import { useRef } from 'react';
4-
import { AnimatePresence, motion, useInView, type UseInViewOptions, type Variants } from 'framer-motion';
4+
import { motion, useInView, type UseInViewOptions, type Variants } from 'motion/react';
55

66
type MarginType = UseInViewOptions['margin'];
77

@@ -24,12 +24,12 @@ export default function BlurFade({
2424
children,
2525
className,
2626
variant,
27-
duration = 0.4,
27+
duration = 0.2,
2828
delay = 0,
29-
yOffset = 12,
29+
yOffset = 8,
3030
inView = false,
3131
inViewMargin = '-50px',
32-
blur = '6px',
32+
blur = '4px',
3333
}: BlurFadeProps) {
3434
const ref = useRef(null);
3535
const inViewResult = useInView(ref, { once: true, margin: inViewMargin });
@@ -40,22 +40,19 @@ export default function BlurFade({
4040
};
4141
const combinedVariants = variant ?? defaultVariants;
4242
return (
43-
<AnimatePresence>
44-
<motion.div
45-
ref={ref}
46-
initial='hidden'
47-
animate={isInView ? 'visible' : 'hidden'}
48-
exit='hidden'
49-
variants={combinedVariants}
50-
transition={{
51-
delay: 0.04 + delay,
52-
duration,
53-
ease: 'easeOut',
54-
}}
55-
className={className}
56-
>
57-
{children}
58-
</motion.div>
59-
</AnimatePresence>
43+
<motion.div
44+
ref={ref}
45+
initial='hidden'
46+
animate={isInView ? 'visible' : 'hidden'}
47+
variants={combinedVariants}
48+
transition={{
49+
delay,
50+
duration,
51+
ease: [0.23, 1, 0.32, 1],
52+
}}
53+
className={className}
54+
>
55+
{children}
56+
</motion.div>
6057
);
6158
}

apps/web/src/app/_core/components/button.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
55
import { cn } from '~/app/_core/utils/cn';
66

77
const buttonVariants = cva(
8-
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
8+
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[transform,background-color,color,border-color,box-shadow] duration-150 ease-out active:scale-[0.97] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:active:scale-100 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
99
{
1010
variants: {
1111
variant: {

0 commit comments

Comments
 (0)