feat(web): add scroll fade hint effect to chat message list

Use CSS mask-image gradients to hint at scrollable overflow in the chat
area. Adds useScrollFade hook that dynamically applies top/bottom fade
based on scroll position via scroll events and ResizeObserver.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-01-30 22:14:19 +08:00
parent 047de2b431
commit 7d326695c1
9 changed files with 114 additions and 6 deletions

View file

@ -1,15 +1,19 @@
"use client";
import { useRef } from "react";
import { SidebarTrigger } from "@multica/ui/components/ui/sidebar";
import { ChatInput } from "@multica/ui/components/chat-input";
import { MemoizedMarkdown } from "@multica/ui/components/markdown";
import { useDeviceStore } from "@multica/store";
import { useMessages } from "../hooks/use-messages";
import { useScrollFade } from "../hooks/use-scroll-fade";
import { cn } from "@multica/ui/lib/utils";
export function Chat() {
const deviceId = useDeviceStore((s) => s.deviceId);
const messages = useMessages();
const mainRef = useRef<HTMLElement>(null);
const fadeStyle = useScrollFade(mainRef);
return (
<div className="h-dvh flex flex-col overflow-hidden w-full">
@ -20,7 +24,7 @@ export function Chat() {
</span>
</header>
<main className="flex-1 overflow-y-auto min-h-0">
<main ref={mainRef} className="flex-1 overflow-y-auto min-h-0" style={fadeStyle}>
<div className="px-4 py-6 space-y-6 max-w-4xl mx-auto">
{messages.map((msg) => (
<div
@ -47,7 +51,7 @@ export function Chat() {
</div>
</main>
<footer className="w-full px-4 max-w-4xl mx-auto">
<footer className="w-full p-2 pt-1 max-w-4xl mx-auto">
<ChatInput />
</footer>
</div>

View file

@ -0,0 +1,67 @@
import { type RefObject, type CSSProperties, useEffect, useState, useCallback } from "react";
/**
* Returns a dynamic maskImage style based on scroll position.
* - At top fade bottom only
* - At bottom fade top only
* - In middle fade both
* - No overflow undefined (no mask)
*/
export function useScrollFade(
ref: RefObject<HTMLElement | null>,
fadeSize = 32
): CSSProperties | undefined {
const [fade, setFade] = useState<"none" | "top" | "bottom" | "both">("none");
const update = useCallback(() => {
const el = ref.current;
if (!el) return;
const { scrollTop, scrollHeight, clientHeight } = el;
const scrollable = scrollHeight - clientHeight;
if (scrollable <= 0) {
setFade("none");
return;
}
const atTop = scrollTop <= 1;
const atBottom = scrollTop >= scrollable - 1;
if (atTop && atBottom) setFade("none");
else if (atTop) setFade("bottom");
else if (atBottom) setFade("top");
else setFade("both");
}, [ref]);
useEffect(() => {
const el = ref.current;
if (!el) return;
update();
el.addEventListener("scroll", update, { passive: true });
const ro = new ResizeObserver(update);
ro.observe(el);
return () => {
el.removeEventListener("scroll", update);
ro.disconnect();
};
}, [ref, update]);
if (fade === "none") return undefined;
const top = fade === "top" || fade === "both" ? `transparent 0%, black ${fadeSize}px` : "black 0%";
const bottom =
fade === "bottom" || fade === "both"
? `black calc(100% - ${fadeSize}px), transparent 100%`
: "black 100%";
const gradient = `linear-gradient(to bottom, ${top}, ${bottom})`;
return {
maskImage: gradient,
WebkitMaskImage: gradient,
};
}

View file

@ -9,6 +9,7 @@
"lint": "eslint"
},
"dependencies": {
"@multica/store": "workspace:*",
"@multica/ui": "workspace:*",
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.4",

View file

@ -4,9 +4,11 @@
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./*": "./src/*.ts"
},
"dependencies": {
"uuid": "^13.0.0",
"zustand": "catalog:"
},
"devDependencies": {

View file

@ -0,0 +1,16 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { v7 as uuidv7 } from 'uuid'
interface DeviceState {
deviceId: string
}
export const useDeviceStore = create<DeviceState>()(
persist(
() => ({
deviceId: uuidv7(),
}),
{ name: 'multica-device' }
)
)

View file

@ -1 +1,2 @@
export { useCounterStore } from './counter'
export { useDeviceStore } from './device'

View file

@ -17,7 +17,7 @@ export function ChatInput() {
};
return (
<div className="bg-card rounded-xl p-3 pt-1 border border-border">
<div className="bg-card rounded-xl p-3 border border-border">
<textarea
ref={textareaRef}
rows={2}

View file

@ -137,6 +137,17 @@
color: var(--shiki-dark) !important;
}
/* Scroll fade hint utilities — mask content edges to hint at scrollable overflow */
.mask-fade-y {
mask-image: linear-gradient(to bottom, transparent 0%, black 32px, black calc(100% - 32px), transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 32px, black calc(100% - 32px), transparent 100%);
}
.mask-fade-bottom {
mask-image: linear-gradient(to bottom, black 0%, black calc(100% - 32px), transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 0%, black calc(100% - 32px), transparent 100%);
}
@layer base {
* {
@apply border-border outline-ring/50;

12
pnpm-lock.yaml generated
View file

@ -208,6 +208,9 @@ importers:
'@hugeicons/react':
specifier: ^1.1.4
version: 1.1.4(react@19.2.3)
'@multica/store':
specifier: workspace:*
version: link:../../packages/store
'@multica/ui':
specifier: workspace:*
version: link:../../packages/ui
@ -258,6 +261,9 @@ importers:
packages/store:
dependencies:
uuid:
specifier: ^13.0.0
version: 13.0.0
zustand:
specifier: 'catalog:'
version: 5.0.10(@types/react@19.2.10)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3))
@ -9975,7 +9981,7 @@ snapshots:
eslint: 9.39.2(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1))
@ -10008,7 +10014,7 @@ snapshots:
tinyglobby: 0.2.15
unrs-resolver: 1.11.1
optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
@ -10023,7 +10029,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9