From d30ae91027e03855c6fc902e106cb8819fc307f6 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Wed, 26 Aug 2026 13:28:33 +0000 Subject: [PATCH] Redesign: Add navigation scroll fades (#40286) --- .../redesign/header.module.scss | 34 +++++-- .../navigation_panel/redesign/header.tsx | 11 +-- .../navigation_panel/redesign/index.tsx | 18 +++- .../redesign/styles.module.scss | 19 +++- .../mastodon/hooks/useScrollSensor/index.tsx | 95 +++++++++++++++++++ .../hooks/useScrollSensor/styles.module.scss | 15 +++ 6 files changed, 177 insertions(+), 15 deletions(-) create mode 100644 app/javascript/mastodon/hooks/useScrollSensor/index.tsx create mode 100644 app/javascript/mastodon/hooks/useScrollSensor/styles.module.scss diff --git a/app/javascript/mastodon/features/navigation_panel/redesign/header.module.scss b/app/javascript/mastodon/features/navigation_panel/redesign/header.module.scss index be5a158657a..544c8137c74 100644 --- a/app/javascript/mastodon/features/navigation_panel/redesign/header.module.scss +++ b/app/javascript/mastodon/features/navigation_panel/redesign/header.module.scss @@ -6,12 +6,34 @@ z-index: 1; display: flex; align-items: center; - padding: var(--space-xl) var(--space-lg); - background: linear-gradient( - to top, - transparent, - var(--color-bg-primary) var(--space-xl) - ); + + // We're distributing the bottom spacing between padding and margin so that + // not too much of it overlaps the navigation links when scrolled down + --half-bottom-spacing: calc(var(--space-xl) / 2); + + padding: var(--space-xl) var(--space-lg) var(--half-bottom-spacing); + margin-bottom: var(--half-bottom-spacing); + background: var(--color-bg-primary); + + &::after { + content: ''; + position: absolute; + top: 100%; + inset-inline: 0; + height: var(--space-2xl); + background: linear-gradient( + to bottom, + var(--color-bg-primary), + transparent + ); + pointer-events: none; + opacity: 0; + transition: opacity linear 200ms; + } + + &[data-stuck='true']::after { + opacity: 1; + } } .siteNameLink { diff --git a/app/javascript/mastodon/features/navigation_panel/redesign/header.tsx b/app/javascript/mastodon/features/navigation_panel/redesign/header.tsx index 9176aa9ca77..65b317e55ad 100644 --- a/app/javascript/mastodon/features/navigation_panel/redesign/header.tsx +++ b/app/javascript/mastodon/features/navigation_panel/redesign/header.tsx @@ -1,5 +1,3 @@ -import type React from 'react'; - import { FormattedMessage } from 'react-intl'; import { Link } from 'react-router-dom'; @@ -9,11 +7,12 @@ import { customAppIcon, domain, title } from '@/mastodon/initial_state'; import classes from './header.module.scss'; -export const NavigationHeader: React.FC<{ siteName?: string }> = ({ - siteName, -}) => { +export const NavigationHeader: React.FC<{ + siteName?: string; + isStuck: boolean; +}> = ({ siteName, isStuck }) => { return ( -
+
{customAppIcon && ( diff --git a/app/javascript/mastodon/features/navigation_panel/redesign/index.tsx b/app/javascript/mastodon/features/navigation_panel/redesign/index.tsx index f9d18ff9562..ba52a711898 100644 --- a/app/javascript/mastodon/features/navigation_panel/redesign/index.tsx +++ b/app/javascript/mastodon/features/navigation_panel/redesign/index.tsx @@ -16,6 +16,7 @@ import FediIcon from '@/images/icons/icon_fediverse.svg?react'; import { fetchLists } from '@/mastodon/actions/lists'; import { fetchFollowedHashtags } from '@/mastodon/actions/tags_typed'; import { FOCUS_TARGET } from '@/mastodon/components/navigation_focus_target'; +import { useScrollSensor } from '@/mastodon/hooks/useScrollSensor'; import { useIdentity } from '@/mastodon/identity_context'; import { openNewComposer } from '@/mastodon/reducers/slices/composer'; import { getOrderedLists } from '@/mastodon/selectors/lists'; @@ -81,12 +82,24 @@ export const RedesignNavigationPanel: React.FC<{ siteName?: string }> = ({ const { customFeeds } = useCustomFeeds(); const { followedHashtags } = useFollowedHashtags(); + const { sensor: topSensor, isInViewport: isScrolledToTop } = useScrollSensor({ + placement: 'top', + // Only show overlay fade after a bit of scrolling, as the nav header has + // a bit of bottom spacing where the fade isn't needed yet + tolerance: 36, + }); + const { sensor: bottomSensor, isInViewport: isScrolledToBottom } = + useScrollSensor({ + placement: 'bottom', + }); + return ( ); }; diff --git a/app/javascript/mastodon/features/navigation_panel/redesign/styles.module.scss b/app/javascript/mastodon/features/navigation_panel/redesign/styles.module.scss index 43a4a3ccd4d..29bac75d290 100644 --- a/app/javascript/mastodon/features/navigation_panel/redesign/styles.module.scss +++ b/app/javascript/mastodon/features/navigation_panel/redesign/styles.module.scss @@ -1,6 +1,7 @@ @use '@/styles/mastodon/mixins'; .root { + position: relative; display: flex; flex-direction: column; height: 100%; @@ -18,8 +19,24 @@ bottom: 0; margin-top: auto; padding: var(--space-xl); - padding-top: 0; + padding-top: var(--space-2xs); background-color: var(--color-bg-primary); + + &::before { + content: ''; + position: absolute; + bottom: 100%; + inset-inline: 0; + height: var(--space-2xl); + background: linear-gradient(to top, var(--color-bg-primary), transparent); + pointer-events: none; + opacity: 0; + transition: opacity linear 200ms; + } + + &[data-stuck='true']::before { + opacity: 1; + } } .footerNav { diff --git a/app/javascript/mastodon/hooks/useScrollSensor/index.tsx b/app/javascript/mastodon/hooks/useScrollSensor/index.tsx new file mode 100644 index 00000000000..b98472118fb --- /dev/null +++ b/app/javascript/mastodon/hooks/useScrollSensor/index.tsx @@ -0,0 +1,95 @@ +import type { ComponentProps, CSSProperties } from 'react'; +import { useLayoutEffect, useRef, useState } from 'react'; + +import classNames from 'classnames'; + +import classes from './styles.module.scss'; + +interface ScrollSensorOptions extends ComponentProps<'div'> { + placement?: 'top' | 'bottom'; + tolerance?: number; + // Use document scrolling as reference + global?: boolean; +} + +/** + * Returns a `sensor` element and whether it's visible inside of its + * nearest scrollable parent element (or the document if `global` is set). + * + * Useful for detecting whether a scrollable element was scrolled + * and whether sticky elements are "stuck" (while we wait for CSS + * scroll queries to be more widely supported) without having to rely on + * scroll event listeners or resize and mutation observers. + * + * Place the sensor element directly into the scroll parent as the first + * or last element, and ensure that the scroll parent uses a `position` + * value other than `static`. + */ +export const useScrollSensor = ({ + placement = 'top', + tolerance, + global, + className, + style, + ...props +}: ScrollSensorOptions = {}) => { + const [isInViewport, setIsInViewport] = useState(true); + const sensorRef = useRef(null); + const observerRef = useRef(null); + + useLayoutEffect(() => { + const sensor = sensorRef.current; + + if (!sensor) return; + + // Assign the IntersectionObserver to a ref so we don't + // need to re-create it when the effect reruns + if (!observerRef.current) { + // This could be enhanced with a util to actually walk up + // the tree to find the nearest scrollable element. For now, + // it assumes the immediate parent as the scroll root. + const root = global ? null : sensor.parentElement; + + observerRef.current = new IntersectionObserver( + ([entry]) => { + if (entry) { + setIsInViewport(entry.isIntersecting); + } + }, + { + root, + }, + ); + } + + observerRef.current.observe(sensor); + + return () => { + if (observerRef.current) { + observerRef.current.unobserve(sensor); + } + }; + }, [sensorRef, global]); + + const sensorStyle = tolerance + ? ({ + ...style, + '--tolerance': `${tolerance}px`, + } as CSSProperties) + : style; + + const sensor = ( +
+ ); + + return { + sensor, + isInViewport, + }; +}; diff --git a/app/javascript/mastodon/hooks/useScrollSensor/styles.module.scss b/app/javascript/mastodon/hooks/useScrollSensor/styles.module.scss new file mode 100644 index 00000000000..e60188c066d --- /dev/null +++ b/app/javascript/mastodon/hooks/useScrollSensor/styles.module.scss @@ -0,0 +1,15 @@ +.sensor { + height: 1px; + pointer-events: none; + + &[data-placement='top'] { + position: absolute; + top: var(--tolerance, 3px); + } + + &[data-placement='bottom'] { + position: relative; + bottom: var(--tolerance, 3px); + margin-top: -1px; + } +}