From e080feb4acb1ba38718b601ae12a96013e530710 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 21 Feb 2026 11:24:09 +0200 Subject: [PATCH 1/2] feat(shared): implement shared element transition for post modal using framer-motion - Add framer-motion dependency - Map layoutIds between feed cards and modal for seamless expansion - Remove manual modal overlays and old pull-to-close logic - Ensure smooth layout morphing on open/close Co-authored-by: Cursor --- packages/shared/package.json | 2 + packages/shared/src/components/Feed.tsx | 23 +++----- .../components/cards/article/ArticleList.tsx | 48 +++++++++------- .../cards/common/PostCardHeader.tsx | 2 + .../cards/common/list/FeedItemContainer.tsx | 19 +++++++ .../components/cards/common/list/ListCard.tsx | 17 ++++-- .../modals/BasePostModal.module.css | 36 ++++++++++++ .../src/components/modals/BasePostModal.tsx | 13 +++++ .../src/components/post/BasePostContent.tsx | 4 ++ .../src/components/post/PostContent.tsx | 57 +++++++++++-------- pnpm-lock.yaml | 56 ++++++++++++++++++ 11 files changed, 211 insertions(+), 66 deletions(-) diff --git a/packages/shared/package.json b/packages/shared/package.json index 8245e48a76..cfbcae31c5 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -128,6 +128,7 @@ "@tiptap/starter-kit": "^3.14.0", "check-password-strength": "^2.0.10", "fetch-event-stream": "^0.1.5", + "framer-motion": "^12.23.25", "graphql-ws": "^5.5.5", "jotai": "^2.12.2", "lottie-react": "^2.4.1", @@ -140,6 +141,7 @@ "recharts": "^3.1.2", "remark-gfm": "^3.0.1", "uuid": "^8.3.2", + "vaul": "^1.1.2", "web-vitals": "^3.5.0", "webextension-polyfill": "^0.12.0", "zod": "4.1.8" diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index fc5e8a3adb..68f2360882 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -40,6 +40,8 @@ import { useFeedLayout, useFeedVotePost, useMutationSubscription, + useViewSize, + ViewSize, } from '../hooks'; import { useProfileCompletionCard } from '../hooks/profile/useProfileCompletionCard'; import type { AllFeedPages } from '../lib/query'; @@ -188,6 +190,8 @@ export default function Feed({ const { user } = useContext(AuthContext); const { isFallback, query: routerQuery } = useRouter(); const { openNewTab, spaciness, loadedSettings } = useContext(SettingsContext); + const isLaptopView = useViewSize(ViewSize.Laptop); + const isLaptop = isNullOrUndefined(isLaptopView) || isLaptopView; const { isListMode } = useFeedLayout(); const numCards = currentSettings.numCards[spaciness ?? 'eco']; const isSquadFeed = feedName === OtherFeedPage.Squad; @@ -430,18 +434,6 @@ export default function Feed({ } }, [canFetchMore, isFetching, trackFinishFeed]); - useEffect(() => { - return () => { - document.body.classList.remove('hidden-scrollbar'); - }; - }, []); - - useEffect(() => { - if (!selectedPost) { - document.body.classList.remove('hidden-scrollbar'); - } - }, [selectedPost]); - const onShareClick = useCallback( (post: Post, row?: number, column?: number) => openSharePost({ post, columns: virtualizedNumCards, column, row }), @@ -463,7 +455,6 @@ export default function Feed({ row?: number; column?: number; }) => { - document.body.classList.add('hidden-scrollbar'); callback?.(); setPostModalIndex({ index, row, column }); onOpenModal(index); @@ -484,7 +475,7 @@ export default function Feed({ await onPostClick(post, index, row, column, { skipPostUpdate: true, }); - if (!isAuxClick && !shouldUseListFeedLayout) { + if (!isAuxClick && (!shouldUseListFeedLayout || !isLaptop)) { onPostModalOpen({ index, row, column }); } }; @@ -515,7 +506,7 @@ export default function Feed({ is_ad: isAd, }), ); - if (!shouldUseListFeedLayout) { + if (!shouldUseListFeedLayout || !isLaptop) { onPostModalOpen({ index, row, column }); } }; @@ -627,7 +618,7 @@ export default function Feed({ {!isFetching && !isInitialLoading && !isHorizontal && ( )} - {!shouldUseListFeedLayout && selectedPost && PostModal && ( + {(!shouldUseListFeedLayout || !isLaptop) && selectedPost && PostModal && (
@@ -160,26 +164,28 @@ export const ArticleList = forwardRef(function ArticleList( {!isMobile && actionButtons}
- + + + {isMobile && actionButtons} diff --git a/packages/shared/src/components/cards/common/PostCardHeader.tsx b/packages/shared/src/components/cards/common/PostCardHeader.tsx index 09bab56fc9..f7484ddb61 100644 --- a/packages/shared/src/components/cards/common/PostCardHeader.tsx +++ b/packages/shared/src/components/cards/common/PostCardHeader.tsx @@ -84,6 +84,8 @@ export const PostCardHeader = ({ <> {highlightBookmarkedPost && } { + if ( + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.altKey + ) { + linkProps.onClick?.(event); + return; + } + + event.preventDefault(); + + const card = (event.currentTarget as HTMLElement).closest( + 'article', + ); + + linkProps.onClick?.(event); + }} onFocus={() => setFocus(true)} onBlur={() => setFocus(false)} /> diff --git a/packages/shared/src/components/cards/common/list/ListCard.tsx b/packages/shared/src/components/cards/common/list/ListCard.tsx index c95db84911..00f5c08a32 100644 --- a/packages/shared/src/components/cards/common/list/ListCard.tsx +++ b/packages/shared/src/components/cards/common/list/ListCard.tsx @@ -1,6 +1,7 @@ import type { HTMLAttributes, ReactNode } from 'react'; import React from 'react'; import classNames from 'classnames'; +import { motion } from 'framer-motion'; import type { ReactElement } from 'react-markdown/lib/react-markdown'; import classed from '../../../../lib/classed'; import { Image } from '../../../image/Image'; @@ -8,17 +9,23 @@ import { Image } from '../../../image/Image'; type TitleProps = HTMLAttributes & { lineClamp?: `line-clamp-${number}`; children: ReactNode; + layoutId?: string; }; +const SHARED_TRANSITION = { type: 'spring', stiffness: 400, damping: 35 }; + const Title = ({ className, lineClamp = 'line-clamp-3', children, + layoutId, ...rest }: TitleProps): ReactElement => { return ( -

{children} -

+ ); }; @@ -37,7 +44,7 @@ export const CardContainer = classed('div', 'flex flex-col'); export const CardContent = classed('div', 'flex flex-col mobileXL:flex-row'); export const CardImage = classed( - Image, + motion(Image) as any, 'rounded-12 min-h-[10rem] max-h-[12.5rem] object-cover w-full h-auto mobileXL:max-h-40 mobileXL:w-40 mobileXXL:max-h-56 mobileXXL:w-56', ); @@ -50,10 +57,10 @@ const clickableCardClasses = classNames( export const CardLink = classed('a', clickableCardClasses); export const ListCard = classed( - 'article', + motion.article as any, `group relative w-full flex flex-col py-6 px-4 border-t border-border-subtlest-tertiary rounded-16 hover:bg-surface-float `, ); -export const CardHeader = classed('div', 'flex flex-row items-center mb-2'); +export const CardHeader = classed(motion.div as any, 'flex flex-row items-center mb-2'); diff --git a/packages/shared/src/components/modals/BasePostModal.module.css b/packages/shared/src/components/modals/BasePostModal.module.css index e80f948456..e2dc98c7ca 100644 --- a/packages/shared/src/components/modals/BasePostModal.module.css +++ b/packages/shared/src/components/modals/BasePostModal.module.css @@ -9,5 +9,41 @@ padding: 0; overflow-y: auto; z-index: 100; + opacity: 0; + transition: opacity 250ms ease-out; + } + + & :global(.post-modal-overlay.ReactModal__Overlay--after-open) { + opacity: 1; + } + + & :global(.post-modal-overlay.ReactModal__Overlay--before-close) { + opacity: 0; + transition: opacity 150ms ease-in; + } + + & :global(.post-modal-overlay .modal) { + opacity: 1; + } + + /* Laptop: subtle slide-up animation since there's no morph overlay */ + @media (min-width: 64rem) { + & :global(.post-modal-overlay .modal) { + opacity: 0; + transform: translateY(0.75rem) scale(0.99); + transition: + transform 260ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 260ms cubic-bezier(0.22, 1, 0.36, 1); + } + + & :global(.post-modal-overlay.ReactModal__Overlay--after-open .modal) { + opacity: 1; + transform: none; + } + + & :global(.post-modal-overlay.ReactModal__Overlay--before-close .modal) { + opacity: 0; + transform: translateY(0.75rem) scale(0.99); + } } } diff --git a/packages/shared/src/components/modals/BasePostModal.tsx b/packages/shared/src/components/modals/BasePostModal.tsx index 77312a895b..786b3d2f9b 100644 --- a/packages/shared/src/components/modals/BasePostModal.tsx +++ b/packages/shared/src/components/modals/BasePostModal.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from 'react'; import React, { useState, useCallback } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; import classNames from 'classnames'; import type { ModalProps } from './common/Modal'; import { Modal } from './common/Modal'; @@ -28,6 +29,8 @@ interface BasePostModalProps extends ModalProps { post: Post; } +const SHARED_TRANSITION = { type: 'spring', stiffness: 400, damping: 35 }; + function BasePostModal({ className, children, @@ -81,10 +84,20 @@ function BasePostModal({ ( + + {props.isOpen && ( + + {contentChildren} + + )} + + )} overlayClassName="post-modal-overlay bg-overlay-quaternary-onion" className={classNames( className, diff --git a/packages/shared/src/components/post/BasePostContent.tsx b/packages/shared/src/components/post/BasePostContent.tsx index f414ac479d..f15d3f6ed5 100644 --- a/packages/shared/src/components/post/BasePostContent.tsx +++ b/packages/shared/src/components/post/BasePostContent.tsx @@ -6,6 +6,7 @@ import PostEngagements from './PostEngagements'; import type { BasePostContentProps } from './common'; import { PostHeaderActions } from './PostHeaderActions'; import { ButtonSize } from '../buttons/common'; +import { PostBottomAction } from './PostBottomAction'; const Custom404 = dynamic( () => import(/* webpackChunkName: "custom404" */ '../Custom404'), @@ -64,6 +65,9 @@ export function BasePostContent({ shouldOnboardAuthor={shouldOnboardAuthor} /> )} + {!isPostPage && !!navigationProps?.onClose && ( + navigationProps.onClose?.(null as any)} /> + )} ); } diff --git a/packages/shared/src/components/post/PostContent.tsx b/packages/shared/src/components/post/PostContent.tsx index 29ccdbb477..bfdb1fede9 100644 --- a/packages/shared/src/components/post/PostContent.tsx +++ b/packages/shared/src/components/post/PostContent.tsx @@ -19,6 +19,7 @@ import { useViewPost } from '../../hooks/post'; import { TruncateText } from '../utilities'; import { useFeature } from '../GrowthBookProvider'; import { feature } from '../../lib/featureManagement'; +import { motion } from 'framer-motion'; import { LazyImage } from '../LazyImage'; import { cloudinaryPostImageCoverPlaceholder } from '../../lib/image'; import { withPostById } from './withPostById'; @@ -36,6 +37,8 @@ const PostCodeSnippets = dynamic(() => ), ); +const SHARED_TRANSITION = { type: 'spring', stiffness: 400, damping: 35 }; + export function PostContentRaw({ post, className = {}, @@ -162,18 +165,22 @@ export function PostContentRaw({ post={post} >
- -

+ + + {title} -

+ {post.clickbaitTitleDetected && }
{isVideoType && ( @@ -208,22 +215,24 @@ export function PostContentRaw({ } /> {!isVideoType && ( - - - + + + + + )} {post.toc?.length > 0 && ( = 10'} @@ -11831,6 +11856,28 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 + '@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.12)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.1(@types/react@18.3.12)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-direction@1.1.1(@types/react@18.3.12)(react@18.3.1)': dependencies: react: 18.3.1 @@ -20065,6 +20112,15 @@ snapshots: spdx-correct: 3.2.0 spdx-expression-parse: 3.0.1 + vaul@1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + vercel@21.3.3: dependencies: '@vercel/build-utils': 2.10.1 From 21fff65af6f576446ebb5935132730b0ce6dbb51 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 21 Feb 2026 11:24:58 +0200 Subject: [PATCH 2/2] feat(shared): add PostBottomAction component for pull-to-close gesture Co-authored-by: Cursor --- .../src/components/post/PostBottomAction.tsx | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 packages/shared/src/components/post/PostBottomAction.tsx diff --git a/packages/shared/src/components/post/PostBottomAction.tsx b/packages/shared/src/components/post/PostBottomAction.tsx new file mode 100644 index 0000000000..72f38c5f94 --- /dev/null +++ b/packages/shared/src/components/post/PostBottomAction.tsx @@ -0,0 +1,227 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useState, useRef, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import classNames from 'classnames'; +import { ArrowIcon } from '../icons'; +import { useViewSize, ViewSize } from '../../hooks'; + +interface PostBottomActionProps { + onAction: () => void; +} + +const THRESHOLD = 120; +const MAX_PULL = 200; +const BOTTOM_TOLERANCE = 24; + +export function PostBottomAction({ + onAction, +}: PostBottomActionProps): ReactElement { + const isLaptop = useViewSize(ViewSize.Laptop); + const [pullDistance, setPullDistance] = useState(0); + const [atBottom, setAtBottom] = useState(false); + const [overlayFound, setOverlayFound] = useState(false); + + const pullRef = useRef(0); + const touchStartY = useRef(null); + const gestureActive = useRef(false); + const closingRef = useRef(false); + const overlayRef = useRef(null); + const sentinelRef = useRef(null); + + const findOverlay = useCallback((): HTMLElement | null => { + if (overlayRef.current && overlayRef.current.isConnected) { + return overlayRef.current; + } + + const fromSentinel = sentinelRef.current?.closest( + '.post-modal-overlay', + ) as HTMLElement | null; + if (fromSentinel) { + overlayRef.current = fromSentinel; + return fromSentinel; + } + + const fromDocument = document.querySelector( + '.post-modal-overlay', + ) as HTMLElement | null; + overlayRef.current = fromDocument; + return fromDocument; + }, []); + + const isScrolledToBottom = useCallback( + (overlay: HTMLElement) => { + return ( + overlay.scrollTop + overlay.clientHeight >= + overlay.scrollHeight - BOTTOM_TOLERANCE + ); + }, + [], + ); + + useEffect(() => { + if (isLaptop) return undefined; + + let retryTimer: ReturnType; + let overlay = findOverlay(); + + if (!overlay) { + retryTimer = setTimeout(() => { + overlay = findOverlay(); + if (overlay) setOverlayFound(true); + }, 300); + return () => clearTimeout(retryTimer); + } + + setOverlayFound(true); + + const onScroll = () => { + if (!gestureActive.current && overlay) { + setAtBottom(isScrolledToBottom(overlay)); + } + }; + + const onTouchStart = (e: TouchEvent) => { + if (!overlay || !isScrolledToBottom(overlay)) { + touchStartY.current = null; + gestureActive.current = false; + return; + } + touchStartY.current = e.touches[0].clientY; + gestureActive.current = false; + }; + + const onTouchMove = (e: TouchEvent) => { + if (touchStartY.current === null) return; + + const dy = touchStartY.current - e.touches[0].clientY; + + if (dy > 8) { + gestureActive.current = true; + const damped = Math.min((dy - 8) * 0.55, MAX_PULL); + pullRef.current = damped; + setPullDistance(damped); + } else if (gestureActive.current && dy <= 0) { + pullRef.current = 0; + setPullDistance(0); + } + }; + + const onTouchEnd = () => { + if (gestureActive.current && pullRef.current >= THRESHOLD) { + if (!closingRef.current) { + closingRef.current = true; + onAction(); + setTimeout(() => { + closingRef.current = false; + }, 400); + } + } + touchStartY.current = null; + gestureActive.current = false; + pullRef.current = 0; + setPullDistance(0); + }; + + overlay.addEventListener('scroll', onScroll, { passive: true }); + overlay.addEventListener('touchstart', onTouchStart, { passive: true }); + overlay.addEventListener('touchmove', onTouchMove, { passive: true }); + overlay.addEventListener('touchend', onTouchEnd); + overlay.addEventListener('touchcancel', onTouchEnd); + + onScroll(); + + return () => { + if (!overlay) return; + overlay.removeEventListener('scroll', onScroll); + overlay.removeEventListener('touchstart', onTouchStart); + overlay.removeEventListener('touchmove', onTouchMove); + overlay.removeEventListener('touchend', onTouchEnd); + overlay.removeEventListener('touchcancel', onTouchEnd); + }; + }, [isLaptop, onAction, findOverlay, isScrolledToBottom, overlayFound]); + + useEffect(() => { + if (pullDistance >= THRESHOLD && navigator.vibrate) { + navigator.vibrate(10); + } + }, [pullDistance >= THRESHOLD]); + + if (isLaptop) return ; + + const progress = Math.min(pullDistance / THRESHOLD, 1); + const isTriggered = pullDistance >= THRESHOLD; + const circumference = 2 * Math.PI * 18; + const dashOffset = circumference - progress * circumference; + const showPull = pullDistance > 0; + const showIdle = !showPull && atBottom; + + const indicator = (showPull || showIdle) ? ( +
+ {showPull && ( +
+ + + + + +
+ )} + {showIdle && ( +
+ + +
+ )} +
+ ) : null; + + return ( + <> + + {typeof document !== 'undefined' && + indicator && + createPortal(indicator, document.body)} + + ); +}