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,73 +300,77 @@ export default function PostPage() {
}; };
return ( return (
<> <Show
<Show when={data()}
when={data()} fallback={
fallback={ <div class="flex h-screen items-center justify-center">
<div class="flex h-screen items-center justify-center"> <Spinner size="xl" />
<Spinner size="xl" /> </div>
</div> }
>
{(loadedData) => {
// Handle redirect for by-id route
if ("redirect" in loadedData()) {
return <Navigate href={(loadedData() as any).redirect} />;
} }
>
{(loadedData) => {
// Handle redirect for by-id route
if ("redirect" in loadedData()) {
return <Navigate href={(loadedData() as any).redirect} />;
}
return ( return (
<Show <Show
when={loadedData().post as Post} when={loadedData().post as Post}
fallback={<Navigate href="/404" />} fallback={<Navigate href="/404" />}
> >
{(p) => { {(p) => {
const postData = loadedData(); const postData = loadedData();
// Convert arrays back to Maps for component // Convert arrays back to Maps for component
const userCommentMap = new Map<UserPublicData, number[]>( const userCommentMap = new Map<UserPublicData, number[]>(
postData.userCommentArray || [] postData.userCommentArray || []
); );
const reactionMap = new Map<number, CommentReaction[]>( const reactionMap = new Map<number, CommentReaction[]>(
postData.reactionArray || [] postData.reactionArray || []
); );
return ( return (
<> <>
<Title> <Title>
{p().title.replaceAll("_", " ")} | Michael Freno {p().title.replaceAll("_", " ")} | Michael Freno
</Title> </Title>
<Meta <Meta
name="description" name="description"
content={ content={
p().subtitle || p().subtitle ||
`Read ${p().title.replaceAll("_", " ")} by Michael Freno on the freno.me blog.` `Read ${p().title.replaceAll("_", " ")} by Michael Freno on the freno.me blog.`
} }
/> />
<div class="blog-overide relative -mt-16 overflow-x-hidden"> <div class="blog-overide relative -mt-16 overflow-x-hidden">
{/* Fixed banner image background */} {/* Fixed banner image background */}
<div class="fixed inset-0 top-0 left-0 z-0 aspect-auto max-h-3/4 w-full overflow-hidden brightness-75 md:ml-62.5 md:max-h-[50vh] md:w-[calc(100vw-500px)]"> <div class="fixed inset-0 top-0 left-0 z-0 aspect-auto max-h-3/4 w-full overflow-hidden brightness-75 md:ml-62.5 md:max-h-[50vh] md:w-[calc(100vw-500px)]">
<img <img
src={p().banner_photo || "/blueprint.jpg"} src={p().banner_photo || "/blueprint.jpg"}
alt="post-cover" alt="post-cover"
class="h-full w-full object-cover select-none" class="h-full w-full object-cover select-none"
style={{ style={{
"pointer-events": "none" "pointer-events": "none"
}} }}
/> />
<div class="fixed top-24 z-50 m-auto w-full px-4 text-center tracking-widest text-white backdrop-blur-md select-text text-shadow-lg backdrop:brightness-50 sm:top-36 md:top-[20vh] md:w-[calc(100vw-500px)]"> <div class="fixed top-24 z-50 m-auto w-full px-4 text-center tracking-widest text-white backdrop-blur-md select-text text-shadow-lg backdrop:brightness-50 sm:top-36 md:top-[20vh] md:w-[calc(100vw-500px)]">
<div class="py-8 text-3xl font-semibold tracking-widest"> <div class="py-8 text-3xl font-semibold tracking-widest">
{p().title.replaceAll("_", " ")} {p().title.replaceAll("_", " ")}
<Show when={p().subtitle}> <Show when={p().subtitle}>
<div class="py-8 text-xl font-light tracking-widest"> <div class="py-8 text-xl font-light tracking-widest">
{p().subtitle} {p().subtitle}
</div> </div>
</Show> </Show>
</div>
</div> </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,14 +506,14 @@ export default function PostPage() {
</div> </div>
</div> </div>
</div> </div>
</div> </CustomScrollbar>
</> </div>
); </>
}} );
</Show> }}
); </Show>
}} );
</Show> }}
</> </Show>
); );
} }