Skip to content

Commit f5eaf2f

Browse files
interacseanclaude
andcommitted
feat: scroll-fade under pinned breadcrumb on content-area scroll
Once the content area has scrolled, a mask fades its top edge to transparent so content dissolves into the pinned breadcrumb rather than cutting off abruptly. Masking (rather than overlaying a matching colour) reveals the real page backdrop — which is a theme gradient with background-attachment: fixed, so no single colour could match it — making the fade seamless on every theme. Toggled by a lightweight scroll listener (re-renders only when crossing scrollTop 0), so content isn't faded at rest and pages whose content fits show nothing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8542c40 commit f5eaf2f

1 file changed

Lines changed: 23 additions & 0 deletions

File tree

packages/core/src/components/sidebar/sidebar-layout.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useRef, useState } from "react";
12
import { SidebarProvider, SidebarInset, SidebarTrigger, useSidebar } from "@/components/sidebar";
23
import { AppShellOutlet } from "@/components/content";
34
import { AppearanceSwitcher } from "@/components/appearance-switcher";
@@ -68,6 +69,17 @@ const HidableSidebarTrigger = () => {
6869
export const SidebarLayout = (props: SidebarLayoutProps) => {
6970
const Children = props.children ? props.children({ Outlet: AppShellOutlet }) : null;
7071

72+
// Toggle a scroll-fade under the pinned breadcrumb once the content area
73+
// has scrolled, so content dissolves into the header instead of cutting off
74+
// abruptly. State only flips when crossing 0, so this doesn't re-render on
75+
// every scroll frame.
76+
const scrollRef = useRef<HTMLDivElement>(null);
77+
const [scrolled, setScrolled] = useState(false);
78+
const handleScroll = () => {
79+
const next = (scrollRef.current?.scrollTop ?? 0) > 0;
80+
setScrolled((prev) => (prev === next ? prev : next));
81+
};
82+
7183
return (
7284
<SidebarProvider
7385
defaultOpen={props.defaultOpen}
@@ -100,11 +112,22 @@ export const SidebarLayout = (props: SidebarLayoutProps) => {
100112
margin / padding pair mirrors SidebarInset's own responsive
101113
padding (px-4, → px-8 at md when the sidebar is the inset
102114
variant). */}
115+
{/* Scroll-fade: once scrolled, a mask fades the top edge of the
116+
content to transparent so it dissolves into the pinned breadcrumb
117+
rather than cutting off. Masking (rather than overlaying a matching
118+
colour) reveals the real page backdrop — which is a theme gradient
119+
with `background-attachment: fixed`, so no single colour could
120+
match it — making the fade seamless on every theme. Only applied
121+
when scrolled, so content isn't faded at rest. */}
103122
<div
123+
ref={scrollRef}
124+
onScroll={handleScroll}
125+
data-scrolled={scrolled ? "" : undefined}
104126
className={cn(
105127
"astw:flex astw:flex-col astw:gap-4 astw:flex-1 astw:min-h-0 astw:overflow-y-auto",
106128
"astw:-mr-4 astw:pr-4",
107129
"astw:md:group-has-data-[variant=inset]/sidebar-wrapper:-mr-8 astw:md:group-has-data-[variant=inset]/sidebar-wrapper:pr-8",
130+
"astw:data-[scrolled]:[mask-image:linear-gradient(to_bottom,transparent,black_2rem)]",
108131
)}
109132
>
110133
{Children ?? <AppShellOutlet />}

0 commit comments

Comments
 (0)