diff --git a/app/features/calendar/routes/calendar.module.css b/app/features/calendar/routes/calendar.module.css
index d35dc6403..465eab581 100644
--- a/app/features/calendar/routes/calendar.module.css
+++ b/app/features/calendar/routes/calendar.module.css
@@ -107,6 +107,7 @@
justify-content: safe center;
overflow-x: auto;
scroll-snap-type: x mandatory;
+ user-select: none;
& > * {
scroll-snap-align: start;
diff --git a/app/features/calendar/routes/calendar.tsx b/app/features/calendar/routes/calendar.tsx
index 4ef503f8e..04a0504c1 100644
--- a/app/features/calendar/routes/calendar.tsx
+++ b/app/features/calendar/routes/calendar.tsx
@@ -26,6 +26,7 @@ import { Main } from "~/components/Main";
import { DAYS_SHOWN_AT_A_TIME } from "~/features/calendar/calendar-constants";
import { useCollapsableEvents } from "~/features/calendar/calendar-hooks";
import { calendarSearchParams } from "~/features/calendar/calendar-search-params";
+import { dragToScroll } from "~/hooks/useDragToScroll";
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
import { dayMonthYearToDateValue } from "~/utils/dates";
import { metaTags, ogPageImage } from "~/utils/remix";
@@ -106,7 +107,7 @@ export default function CalendarPage() {
{shown.map((date) => (
@@ -206,6 +207,13 @@ function useCalendarDayHref() {
calendarSearchParams.href(CALENDAR_PAGE, { ...params, ...dayMonthYear });
}
+function setUpColumnsContainer(container: HTMLDivElement | null) {
+ scrollTodayToCenter(container);
+ if (!container) return;
+
+ return dragToScroll(container);
+}
+
/** Centers today's column, leaving weeks that don't contain today scrolled to their first day. */
function scrollTodayToCenter(container: HTMLDivElement | null) {
if (!container) return;
diff --git a/app/hooks/useDragToScroll.browser.test.ts b/app/hooks/useDragToScroll.browser.test.ts
index 502a80dc5..1152bf3ff 100644
--- a/app/hooks/useDragToScroll.browser.test.ts
+++ b/app/hooks/useDragToScroll.browser.test.ts
@@ -10,12 +10,21 @@ afterEach(() => {
cleanupFns = [];
});
-function setUpScrollableElement() {
+function setUpScrollableElement({ scrollSnap = false } = {}) {
const element = document.createElement("div");
element.style.width = "100px";
element.style.height = "100px";
element.style.overflow = "scroll";
+ if (scrollSnap) {
+ const snapStyle = document.createElement("style");
+ snapStyle.textContent =
+ ".test-scroll-snap { scroll-snap-type: x mandatory; }";
+ document.head.appendChild(snapStyle);
+ element.classList.add("test-scroll-snap");
+ cleanupFns.push(() => snapStyle.remove());
+ }
+
const child = document.createElement("div");
child.style.width = "1000px";
child.style.height = "1000px";
@@ -138,4 +147,73 @@ describe("dragToScroll", () => {
expect(element.scrollLeft).toBe(0);
});
+ test("suppresses scroll snap while dragging a snap container", () => {
+ const { element } = setUpScrollableElement({ scrollSnap: true });
+
+ mouseDownOn(element, 50, 50);
+ mouseMoveTo(20, 50);
+
+ expect(getComputedStyle(element).scrollSnapType).toBe("none");
+ mouseUp();
+ });
+
+ test("restores scroll snap smoothly after a drag without momentum", () => {
+ const { element } = setUpScrollableElement({ scrollSnap: true });
+
+ mouseDownOn(element, 50, 50);
+ mouseMoveTo(45, 50);
+ mouseUp();
+
+ expect(getComputedStyle(element).scrollSnapType).toBe("x mandatory");
+ expect(element.style.scrollBehavior).toBe("smooth");
+
+ element.dispatchEvent(new Event("scrollend"));
+ expect(element.style.scrollBehavior).toBe("");
+ });
+
+ test("restores scroll snap after momentum scrolling ends", async () => {
+ const { element } = setUpScrollableElement({ scrollSnap: true });
+
+ mouseDownOn(element, 90, 50);
+ mouseMoveTo(82, 50);
+ mouseMoveTo(74, 50);
+ mouseUp();
+
+ expect(getComputedStyle(element).scrollSnapType).toBe("none");
+
+ await vi.waitFor(
+ () => {
+ expect(getComputedStyle(element).scrollSnapType).toBe("x mandatory");
+ },
+ { timeout: 3000 },
+ );
+ });
+
+ test("clears the smooth scroll-behavior via the fallback timeout when no scrollend fires", () => {
+ vi.useFakeTimers();
+ try {
+ const { element } = setUpScrollableElement({ scrollSnap: true });
+
+ mouseDownOn(element, 50, 50);
+ mouseMoveTo(45, 50);
+ mouseUp();
+ expect(element.style.scrollBehavior).toBe("smooth");
+
+ vi.advanceTimersByTime(750);
+ expect(element.style.scrollBehavior).toBe("");
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ test("clears snap suppression on cleanup mid-drag", () => {
+ const { element, detach } = setUpScrollableElement({ scrollSnap: true });
+
+ mouseDownOn(element, 50, 50);
+ mouseMoveTo(20, 50);
+ detach();
+
+ expect(getComputedStyle(element).scrollSnapType).toBe("x mandatory");
+ expect(element.style.scrollBehavior).toBe("");
+ });
});
diff --git a/app/hooks/useDragToScroll.ts b/app/hooks/useDragToScroll.ts
index c221fb1a0..85ebe2654 100644
--- a/app/hooks/useDragToScroll.ts
+++ b/app/hooks/useDragToScroll.ts
@@ -5,6 +5,7 @@ const MOMENTUM_DECAY_PER_FRAME = 0.95;
const MOMENTUM_MIN_SPEED_PX_PER_MS = 0.05;
const MOMENTUM_FRAME_MS = 1000 / 60;
const VELOCITY_IDLE_TIMEOUT_MS = 100;
+const SNAP_RESTORE_SMOOTH_TIMEOUT_MS = 750;
/**
* Makes an element with overflowing content scrollable by dragging with the
@@ -31,7 +32,10 @@ export function useDragToScroll<
* Framework-agnostic on purpose (usable as a Svelte attachment as is).
*/
export function dragToScroll(element: HTMLElement): () => void {
+ const hasScrollSnap = getComputedStyle(element).scrollSnapType !== "none";
let grabbingCursorStyle: HTMLStyleElement | null = null;
+ let isSnapSuppressed = false;
+ let snapRestoreTimeout = 0;
let isMouseDown = false;
let isDragging = false;
let suppressNextClick = false;
@@ -44,6 +48,35 @@ export function dragToScroll(element: HTMLElement): () => void {
let velocityY = 0;
let momentumFrame = 0;
+ const clearSmoothSnapRestore = () => {
+ window.clearTimeout(snapRestoreTimeout);
+ element.removeEventListener("scrollend", clearSmoothSnapRestore);
+ element.style.scrollBehavior = "";
+ };
+
+ // mandatory scroll snap re-snaps on every programmatic scrollLeft/scrollTop
+ // write, so it has to be off for the duration of the drag
+ const suppressScrollSnap = () => {
+ if (!hasScrollSnap || isSnapSuppressed) return;
+
+ isSnapSuppressed = true;
+ clearSmoothSnapRestore();
+ element.style.scrollSnapType = "none";
+ };
+
+ const restoreScrollSnap = () => {
+ if (!isSnapSuppressed) return;
+
+ isSnapSuppressed = false;
+ element.style.scrollBehavior = "smooth";
+ element.style.scrollSnapType = "";
+ element.addEventListener("scrollend", clearSmoothSnapRestore);
+ snapRestoreTimeout = window.setTimeout(
+ clearSmoothSnapRestore,
+ SNAP_RESTORE_SMOOTH_TIMEOUT_MS,
+ );
+ };
+
const onMouseDown = (event: MouseEvent) => {
suppressNextClick = false;
if (event.buttons !== 1) return;
@@ -66,6 +99,7 @@ export function dragToScroll(element: HTMLElement): () => void {
event.preventDefault();
grabbingCursorStyle ??= createGrabbingCursorStyle();
+ suppressScrollSnap();
const now = performance.now();
const elapsedMs = Math.max(now - lastMoveAt, 1);
@@ -104,6 +138,8 @@ export function dragToScroll(element: HTMLElement): () => void {
if (isDragging && !idledSinceLastMove) {
previousFrameAt = performance.now();
momentumFrame = requestAnimationFrame(momentumScrollStep);
+ } else {
+ restoreScrollSnap();
}
isDragging = false;
};
@@ -132,6 +168,8 @@ export function dragToScroll(element: HTMLElement): () => void {
Math.abs(velocityY) > MOMENTUM_MIN_SPEED_PX_PER_MS
) {
momentumFrame = requestAnimationFrame(momentumScrollStep);
+ } else {
+ restoreScrollSnap();
}
};
@@ -147,6 +185,8 @@ export function dragToScroll(element: HTMLElement): () => void {
window.removeEventListener("mouseup", onMouseUp);
cancelAnimationFrame(momentumFrame);
grabbingCursorStyle?.remove();
+ clearSmoothSnapRestore();
+ element.style.scrollSnapType = "";
};
}
diff --git a/changelog/2026-08-31-calendar-drag-scroll.md b/changelog/2026-08-31-calendar-drag-scroll.md
new file mode 100644
index 000000000..0a61633e5
--- /dev/null
+++ b/changelog/2026-08-31-calendar-drag-scroll.md
@@ -0,0 +1,5 @@
+---
+navItem: calendar
+type: feature
+---
+Calendar can now be scrolled by dragging with the mouse