custom scrollbar for blog page - hate this

This commit is contained in:
Michael Freno
2026-01-04 00:37:25 -05:00
parent a1f0ae0cc3
commit 999ff25fc3
2 changed files with 280 additions and 68 deletions

View File

@@ -0,0 +1,207 @@
import { createSignal, onMount, onCleanup, JSX, Show } from "solid-js";
export interface CustomScrollbarProps {
autoHide?: boolean;
autoHideDelay?: number;
rightOffset?: number;
children: JSX.Element;
}
export default function CustomScrollbar(props: CustomScrollbarProps) {
const [scrollPercentage, setScrollPercentage] = createSignal(0);
const [thumbHeight, setThumbHeight] = createSignal(100);
const [isDragging, setIsDragging] = createSignal(false);
const [isVisible, setIsVisible] = createSignal(true);
const [isHovering, setIsHovering] = createSignal(false);
const [windowWidth, setWindowWidth] = createSignal(
typeof window !== "undefined" ? window.innerWidth : 1024
);
let containerRef: HTMLDivElement | undefined;
let scrollbarRef: HTMLDivElement | undefined;
let thumbRef: HTMLDivElement | undefined;
let hideTimeout: NodeJS.Timeout | undefined;
const updateScrollbar = () => {
if (!containerRef) return;
const scrollTop = containerRef.scrollTop;
const scrollHeight = containerRef.scrollHeight;
const clientHeight = containerRef.clientHeight;
// Calculate thumb height as percentage of visible area
const viewportRatio = clientHeight / scrollHeight;
const calculatedThumbHeight = Math.max(viewportRatio * 100, 5);
setThumbHeight(calculatedThumbHeight);
// Calculate scroll percentage
const maxScroll = scrollHeight - clientHeight;
const percentage = maxScroll > 0 ? (scrollTop / maxScroll) * 100 : 0;
setScrollPercentage(percentage);
// Show scrollbar on scroll if autoHide enabled
if (props.autoHide) {
setIsVisible(true);
clearTimeout(hideTimeout);
hideTimeout = setTimeout(() => {
if (!isDragging()) {
setIsVisible(false);
}
}, props.autoHideDelay || 1500);
}
};
const handleTrackClick = (e: MouseEvent) => {
if (!scrollbarRef || !containerRef || e.target === thumbRef) return;
const rect = scrollbarRef.getBoundingClientRect();
const clickY = e.clientY - rect.top;
const trackHeight = rect.height;
const targetPercentage = (clickY / trackHeight) * 100;
const scrollHeight = containerRef.scrollHeight;
const clientHeight = containerRef.clientHeight;
const maxScroll = scrollHeight - clientHeight;
const targetScroll = (targetPercentage / 100) * maxScroll;
containerRef.scrollTop = targetScroll;
};
const handleThumbMouseDown = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
const startY = e.clientY;
const startScrollPercentage = scrollPercentage();
const handleMouseMove = (moveEvent: MouseEvent) => {
if (!containerRef || !scrollbarRef) return;
const deltaY = moveEvent.clientY - startY;
const trackHeight = scrollbarRef.getBoundingClientRect().height || 0;
const deltaPercentage = (deltaY / trackHeight) * 100;
const newPercentage = Math.max(
0,
Math.min(100, startScrollPercentage + deltaPercentage)
);
const scrollHeight = containerRef.scrollHeight;
const clientHeight = containerRef.clientHeight;
const maxScroll = scrollHeight - clientHeight;
const targetScroll = (newPercentage / 100) * maxScroll;
containerRef.scrollTop = targetScroll;
};
const handleMouseUp = () => {
setIsDragging(false);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
};
onMount(() => {
if (!containerRef) return;
// Initial update
updateScrollbar();
// Update after delays to catch dynamically loaded content
setTimeout(() => updateScrollbar(), 100);
setTimeout(() => updateScrollbar(), 500);
const handleResize = () => {
setWindowWidth(window.innerWidth);
updateScrollbar();
};
// Debounced mutation observer
let mutationTimeout: NodeJS.Timeout;
const observer = new MutationObserver(() => {
clearTimeout(mutationTimeout);
mutationTimeout = setTimeout(() => {
updateScrollbar();
}, 150);
});
observer.observe(containerRef, {
childList: true,
subtree: true
});
// Use passive scroll listener for better performance
containerRef.addEventListener("scroll", updateScrollbar, { passive: true });
window.addEventListener("resize", handleResize);
onCleanup(() => {
observer.disconnect();
clearTimeout(mutationTimeout);
containerRef?.removeEventListener("scroll", updateScrollbar);
window.removeEventListener("resize", handleResize);
if (hideTimeout) clearTimeout(hideTimeout);
});
});
const getRightOffset = () => {
const baseOffset = props.rightOffset || 0;
return windowWidth() >= 768 ? baseOffset : 0;
};
return (
<div
ref={containerRef}
class="relative h-screen w-full overflow-x-hidden overflow-y-auto"
style={{
"scrollbar-width": "none",
"-ms-overflow-style": "none"
}}
>
{/* Hide default scrollbar */}
<style>
{`
div::-webkit-scrollbar {
display: none;
}
`}
</style>
{props.children}
{/* Custom scrollbar */}
<Show when={thumbHeight() < 100}>
<div
ref={scrollbarRef}
onClick={handleTrackClick}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
class="fixed top-0 h-full w-3 transition-opacity duration-300"
classList={{
"opacity-0":
props.autoHide && !isVisible() && !isDragging() && !isHovering(),
"opacity-100":
!props.autoHide || isVisible() || isDragging() || isHovering()
}}
style={{
right: `${getRightOffset()}px`,
"z-index": "9999"
}}
>
<div
ref={thumbRef}
onMouseDown={handleThumbMouseDown}
class="absolute right-0.5 w-2 cursor-pointer rounded-full hover:w-2.5"
style={{
height: `${Math.max(thumbHeight(), 5)}%`,
top: `${(scrollPercentage() / 100) * (100 - thumbHeight())}%`,
background: isDragging() ? "#4b5563" : "#9ca3af",
transition: "width 0.15s, background 0.15s"
}}
/>
</div>
</Show>
</div>
);
}

View File

@@ -17,6 +17,7 @@ import PostBodyClient from "~/components/blog/PostBodyClient";
import type { Comment, CommentReaction, UserPublicData } from "~/types/comment"; import type { Comment, CommentReaction, UserPublicData } from "~/types/comment";
import { Spinner } from "~/components/Spinner"; import { Spinner } from "~/components/Spinner";
import { api } from "~/lib/api"; import { api } from "~/lib/api";
import CustomScrollbar from "~/components/CustomScrollbar";
import "../post.css"; import "../post.css";
import { Post } from "~/db/types"; import { Post } from "~/db/types";
@@ -299,7 +300,6 @@ export default function PostPage() {
}; };
return ( return (
<>
<Show <Show
when={data()} when={data()}
fallback={ fallback={
@@ -366,6 +366,11 @@ export default function PostPage() {
</div> </div>
</div> </div>
<CustomScrollbar
autoHide={true}
autoHideDelay={1500}
rightOffset={250}
>
<div class="z-10 pt-80 backdrop-blur-[0.01px] sm:pt-96 md:pt-[50vh]"> <div class="z-10 pt-80 backdrop-blur-[0.01px] sm:pt-96 md:pt-[50vh]">
{/* Content that slides over the fixed image */} {/* Content that slides over the fixed image */}
<div class="bg-base relative pb-24"> <div class="bg-base relative pb-24">
@@ -501,6 +506,7 @@ export default function PostPage() {
</div> </div>
</div> </div>
</div> </div>
</CustomScrollbar>
</div> </div>
</> </>
); );
@@ -509,6 +515,5 @@ export default function PostPage() {
); );
}} }}
</Show> </Show>
</>
); );
} }