scroll-to-bottom ↓ button above compose when scrolled up

- Appears when >150px from bottom
- Smooth scrolls on click
- Sending a message auto-scrolls to bottom
- Proper scroll tracking via viewport event listener

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 21:28:24 +02:00
parent 2b1ed18cde
commit 869f3ad001

View File

@@ -45,6 +45,7 @@ export default function App() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [replyTo, setReplyTo] = useState<{ id: string; username: string; content: string } | null>(null); const [replyTo, setReplyTo] = useState<{ id: string; username: string; content: string } | null>(null);
const [sheetOpen, setSheetOpen] = useState(false); const [sheetOpen, setSheetOpen] = useState(false);
const [showScrollDown, setShowScrollDown] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const prevMsgCountRef = useRef(0); const prevMsgCountRef = useRef(0);
const activeChannelRef = useRef(activeChannelId); const activeChannelRef = useRef(activeChannelId);
@@ -97,14 +98,36 @@ export default function App() {
loadMessages(); loadMessages();
}, [activeChannelId, loadMessages]); }, [activeChannelId, loadMessages]);
function getViewport() {
const el = scrollRef.current as unknown as HTMLElement | null;
return el?.querySelector('[data-slot="scroll-area-viewport"]') as HTMLElement | null ?? el;
}
function scrollToBottom(smooth = false) {
const vp = getViewport();
if (vp) vp.scrollTo({ top: vp.scrollHeight, behavior: smooth ? "smooth" : "instant" });
}
// Auto-scroll only on new messages // Auto-scroll only on new messages
useEffect(() => { useEffect(() => {
if (messages.length > prevMsgCountRef.current && scrollRef.current) { if (messages.length > prevMsgCountRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; scrollToBottom();
} }
prevMsgCountRef.current = messages.length; prevMsgCountRef.current = messages.length;
}, [messages]); }, [messages]);
// Track scroll position for scroll-down button
useEffect(() => {
const vp = getViewport();
if (!vp) return;
function onScroll() {
const v = vp!;
setShowScrollDown(v.scrollHeight - v.scrollTop - v.clientHeight > 150);
}
vp.addEventListener("scroll", onScroll, { passive: true });
return () => vp.removeEventListener("scroll", onScroll);
});
const messagesById = new Map(messages.map((m) => [m.id, m])); const messagesById = new Map(messages.map((m) => [m.id, m]));
const activeChannel = channels.find((c) => c.id === activeChannelId); const activeChannel = channels.find((c) => c.id === activeChannelId);
@@ -208,6 +231,19 @@ export default function App() {
)} )}
</ScrollArea> </ScrollArea>
{/* Scroll-to-bottom button */}
{showScrollDown && (
<div className="flex justify-center -mt-6 mb-1 relative z-10">
<button
type="button"
onClick={() => scrollToBottom(true)}
className="w-8 h-8 flex items-center justify-center border-2 border-border bg-card text-muted-foreground hover:text-primary hover:border-primary transition-colors shadow-lg text-sm"
>
</button>
</div>
)}
{activeChannelId && ( {activeChannelId && (
<ComposeBox <ComposeBox
key={activeChannelId} key={activeChannelId}
@@ -216,14 +252,7 @@ export default function App() {
onClearReply={() => setReplyTo(null)} onClearReply={() => setReplyTo(null)}
onMessageSent={() => { onMessageSent={() => {
loadMessages(); loadMessages();
// Force scroll to bottom after sending setTimeout(() => scrollToBottom(), 100);
setTimeout(() => {
if (scrollRef.current) {
const el = scrollRef.current as unknown as HTMLElement;
const viewport = el.querySelector('[data-slot="scroll-area-viewport"]') || el;
viewport.scrollTop = viewport.scrollHeight;
}
}, 100);
}} }}
/> />
)} )}