Redesign: Add navigation scroll fades (#40286)

This commit is contained in:
diondiondion
2026-08-26 13:28:33 +00:00
committed by GitHub
parent d09749109c
commit d30ae91027
6 changed files with 177 additions and 15 deletions

View File

@@ -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 {

View File

@@ -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 (
<header className={classes.root}>
<header className={classes.root} data-stuck={isStuck}>
<Link to='/' className={classes.siteNameLink}>
{customAppIcon && (
<img src={customAppIcon} alt='' className={classes.appIcon} />

View File

@@ -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 (
<nav
className={classes.root}
aria-label={intl.formatMessage(messages.main)}
>
<NavigationHeader siteName={siteName} />
{topSensor}
<NavigationHeader siteName={siteName} isStuck={!isScrolledToTop} />
{signedIn && (
<>
<ul className={classes.list}>
@@ -186,7 +199,7 @@ export const RedesignNavigationPanel: React.FC<{ siteName?: string }> = ({
</ListSection>
)}
</ul>
<footer className={classes.footer}>
<footer className={classes.footer} data-stuck={!isScrolledToBottom}>
<ul className={classes.footerNav}>
<NavigationLink
stacked
@@ -223,6 +236,7 @@ export const RedesignNavigationPanel: React.FC<{ siteName?: string }> = ({
</footer>
</>
)}
{bottomSensor}
</nav>
);
};

View File

@@ -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 {

View File

@@ -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<HTMLDivElement>(null);
const observerRef = useRef<IntersectionObserver>(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 = (
<div
{...props}
className={classNames(classes.sensor, className)}
style={sensorStyle}
data-placement={placement}
ref={sensorRef}
/>
);
return {
sensor,
isInViewport,
};
};

View File

@@ -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;
}
}