fix(ui): merge nav + sticky filter into a single backdrop-filter band
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 7m27s

Two adjacent backdrop-filter elements (nav at y=0-80, filter at y=80+)
always show a visible seam at their boundary because each filter clips
its own blur kernel, so the edge pixels sample slightly different
neighborhoods. Same recipe doesn't help — it's a structural issue.

Fix: when filter is stuck, render an absolutely-positioned glass child
inside the filter that extends from -top-20 to bottom-0 (i.e. covers
nav area + filter area as ONE element). Nav reads filterStuck from a
tiny shared zustand UI store and disables its own glass layer in that
state, so only the shared band is visible. Single element, single
backdrop-filter, no seam.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
iye 2026-05-14 12:42:03 +08:00
parent ed222d1c5f
commit 3f5d33c422
3 changed files with 55 additions and 11 deletions

View File

@ -11,6 +11,7 @@ import { getActivityEndTime, sortArtists, type SortKey } from "@/lib/mock-data";
import { useVoteStore } from "@/lib/store";
import { useVoteAction } from "@/hooks/useVoteAction";
import { useScrollRestore } from "@/hooks/useScrollRestore";
import { useUIStore } from "@/lib/ui-store";
import { cn } from "@/lib/cn";
import { tosUrl } from "@/lib/tos";
@ -23,6 +24,7 @@ export default function Home() {
const [sortKey, setSortKey] = useState<SortKey>("votes");
const [filterStuck, setFilterStuck] = useState(false);
const filterSentinelRef = useRef<HTMLDivElement>(null);
const setStoreFilterStuck = useUIStore((s) => s.setFilterStuck);
const endTime = useMemo(() => getActivityEndTime(), []);
@ -66,6 +68,13 @@ export default function Home() {
};
}, []);
// 把 filterStuck 同步到全局 UI store —— 让导航栏感知,在吸顶时关掉自己的玻璃,
// 让筛选条延伸出的"共享玻璃带"成为唯一的 backdrop-filter,消除接缝
useEffect(() => {
setStoreFilterStuck(filterStuck);
return () => setStoreFilterStuck(false);
}, [filterStuck, setStoreFilterStuck]);
return (
<>
{/* Hero · · snap
@ -119,16 +128,25 @@ export default function Home() {
{/* 哨兵:用于检测筛选条是否吸顶 */}
<div ref={filterSentinelRef} aria-hidden style={{ height: 0 }} />
{/* 筛选条 · 外层铺满(与导航栏同宽),吸顶后启用毛玻璃;内层版心承载文案 */}
{/* · ,
,absolute -top-20
backdrop-filter, nav + filter ,
y=80 */}
<div
className={cn(
"sticky z-30 transition-colors duration-200",
filterStuck &&
"bg-surface/40 backdrop-blur-xl backdrop-saturate-150 border-b border-white/[0.06]",
)}
className="sticky z-30 transition-colors duration-200"
style={{ top: "80px" }}
>
<div className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8">
{/* 共享玻璃带:absolute,吸顶时延伸到 nav 顶部,opacity 平滑过渡 */}
<div
aria-hidden
className={cn(
"absolute inset-x-0 -top-20 bottom-0 pointer-events-none",
"bg-surface/40 backdrop-blur-xl backdrop-saturate-150 border-b border-white/[0.06]",
"transition-opacity duration-300",
filterStuck ? "opacity-100" : "opacity-0",
)}
/>
<div className="relative max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8">
<ArtistFilters
tagFilter={tagFilter}
onTagChange={setTagFilter}

View File

@ -8,6 +8,7 @@ import SearchTrigger from "./SearchTrigger";
import AuthMenu from "./auth/AuthMenu";
import RemainingVotesBadge from "./auth/RemainingVotesBadge";
import { cn } from "@/lib/cn";
import { useUIStore } from "@/lib/ui-store";
/**
* ·
@ -23,6 +24,10 @@ export default function Navigation() {
const pathname = usePathname();
// 初始乐观:任何页面首屏都假设在顶部/Hero 上,透明。effect 挂载后会立刻校正。
const [isTransparent, setIsTransparent] = useState(true);
// 筛选条吸顶时,nav 也变透明 —— 此时筛选条的"共享玻璃带"已延伸到 nav 顶部,
// nav 关掉自己的玻璃,避免双重 backdrop-filter 在 y=80 处出现拼接线。
const filterStuck = useUIStore((s) => s.filterStuck);
const glassOff = isTransparent || filterStuck;
// 维护一个站内导航计数器(per-tab),供 FloatingBackButton 判断 router.back() 是否安全
useEffect(() => {
@ -57,12 +62,15 @@ export default function Navigation() {
return (
<header className="fixed top-0 inset-x-0 z-50">
{/* 玻璃层 · 通过 opacity 渐显渐隐 · bg-surface 与全站卡片同色 */}
{/* · opacity · bg-surface
glassOff = isTransparent || filterStuck:
- isTransparent: Hero / ,
- filterStuck: 筛选条已伸出共享玻璃,nav */}
<div
aria-hidden
className={cn(
"absolute inset-0 bg-surface/40 backdrop-blur-xl backdrop-saturate-150 transition-opacity duration-300",
isTransparent ? "opacity-0" : "opacity-100",
glassOff ? "opacity-0" : "opacity-100",
)}
/>
{/* 顶部 1px 高光 · 仅玻璃态下显示 */}
@ -70,7 +78,7 @@ export default function Navigation() {
aria-hidden
className={cn(
"absolute inset-x-0 top-0 h-px bg-white/[0.06] transition-opacity duration-300",
isTransparent ? "opacity-0" : "opacity-100",
glassOff ? "opacity-0" : "opacity-100",
)}
/>
<nav className="relative max-w-[1500px] mx-auto h-20 px-4 sm:px-6 lg:px-8 flex items-center gap-8">
@ -91,7 +99,7 @@ export default function Navigation() {
<NavLinks
className={cn(
"relative md:hidden overflow-x-auto transition-colors duration-300",
isTransparent
glassOff
? "border-t border-transparent"
: "border-t border-white/[0.05]",
)}

18
src/lib/ui-store.ts Normal file
View File

@ -0,0 +1,18 @@
import { create } from "zustand";
/**
* UI
* 主要用途:首页筛选条吸顶时通知导航关掉自己的玻璃,
* y=0 , backdrop-filter,
* y=80
*/
interface UIStore {
/** 当前页面有 sticky 筛选条且已经吸顶 */
filterStuck: boolean;
setFilterStuck: (stuck: boolean) => void;
}
export const useUIStore = create<UIStore>((set) => ({
filterStuck: false,
setFilterStuck: (stuck) => set({ filterStuck: stuck }),
}));