From aa0795d1f5f7f99688cf1f30198d986bc06ee090 Mon Sep 17 00:00:00 2001 From: owen <185341197+oxwen11@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:31:46 -0400 Subject: [PATCH] Animate the desktop sidebar as a sliding drawer. pie #72 springs the column width and translates the rail so toggle is a push drawer. Drag-resize stays a snap; mobile still uses the Sheet. Co-authored-by: Cursor --- apps/app/package.json | 1 + apps/app/src/components/layout/app-shell.tsx | 3 +- apps/app/src/components/layout/card-panel.tsx | 45 ++++-- .../src/components/layout/shell-panels.tsx | 150 ++++++++++++++++-- pnpm-lock.yaml | 54 +++++++ 5 files changed, 227 insertions(+), 26 deletions(-) diff --git a/apps/app/package.json b/apps/app/package.json index 8b14bfe9e..51f902b89 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -37,6 +37,7 @@ "@xterm/xterm": "^6.0.0", "ai": "catalog:", "lucide-react": "catalog:", + "motion": "^13.1.1", "react": "catalog:react", "react-dom": "catalog:react", "react-error-boundary": "catalog:", diff --git a/apps/app/src/components/layout/app-shell.tsx b/apps/app/src/components/layout/app-shell.tsx index 564ad455c..852195d22 100644 --- a/apps/app/src/components/layout/app-shell.tsx +++ b/apps/app/src/components/layout/app-shell.tsx @@ -1,4 +1,5 @@ import { SidebarProvider, useSidebar } from "@vibest/ui/components/sidebar"; +import { LazyMotion, domMax } from "motion/react"; import { createContext, type ReactNode, use, useCallback, useMemo } from "react"; import { useContentPanel, usePanelSnapshot } from "@/components/layout/content-panel/react/hooks"; @@ -84,7 +85,7 @@ export function AppShell({ children }: AppShellProps) { className="bg-sidebar h-svh overflow-hidden [-webkit-app-region:drag]" defaultOpen={readSidebarCookie()} > - {children} + {children} ); } diff --git a/apps/app/src/components/layout/card-panel.tsx b/apps/app/src/components/layout/card-panel.tsx index 015be16df..4d684878e 100644 --- a/apps/app/src/components/layout/card-panel.tsx +++ b/apps/app/src/components/layout/card-panel.tsx @@ -1,6 +1,8 @@ import { Outlet } from "@tanstack/react-router"; import { SidebarInset, SidebarTrigger, useSidebar } from "@vibest/ui/components/sidebar"; import { cn } from "@vibest/ui/lib/utils"; +import { useReducedMotion } from "motion/react"; +import * as m from "motion/react-m"; import { ContentPanelToggle } from "@/components/layout/content-panel/react/toggle"; @@ -14,6 +16,8 @@ export function CardPanel({ hasTrafficLights, heading, supportingText }: CardPan const { state, isMobile } = useSidebar(); const collapsedDesktop = !isMobile && state === "collapsed"; const ownsToggle = isMobile || collapsedDesktop; + const reduceMotion = useReducedMotion() === true; + const chromeTransition = reduceMotion ? { duration: 0 } : undefined; return ( @@ -23,23 +27,32 @@ export function CardPanel({ hasTrafficLights, heading, supportingText }: CardPan collapsedDesktop && hasTrafficLights && "ps-20", )} > - {ownsToggle && ( - - )} -
- - {heading} - - {supportingText !== undefined && ( - - {supportingText} - +
+ {ownsToggle && ( + )} + + + {heading} + + {supportingText !== undefined && ( + + {supportingText} + + )} +
diff --git a/apps/app/src/components/layout/shell-panels.tsx b/apps/app/src/components/layout/shell-panels.tsx index a63298b3a..7dad5d8ce 100644 --- a/apps/app/src/components/layout/shell-panels.tsx +++ b/apps/app/src/components/layout/shell-panels.tsx @@ -1,11 +1,21 @@ import { useSidebar } from "@vibest/ui/components/sidebar"; import { cn } from "@vibest/ui/lib/utils"; +import { + animate, + useMotionValue, + useMotionValueEvent, + useReducedMotion, + useTransform, +} from "motion/react"; +import * as m from "motion/react-m"; import { type ReactNode, type RefObject, useCallback, + useEffect, useLayoutEffect, useMemo, + useReducer, useRef, } from "react"; import { @@ -23,6 +33,7 @@ import { const SHELL_LAYOUT_ID = "vibest:shell-layout"; const SIDEBAR_DEFAULT_SIZE = "16rem"; +const SIDEBAR_MIN_SIZE = "12rem"; const PANEL_IDS = { content: "content", @@ -130,15 +141,128 @@ function useCollapsedBinding( }; } +/** True while the drawer width has not reached the `open` target. */ +function sidebarDrawerInFlight(open: boolean, px: number, expandedPx: number): boolean { + return open ? px < expandedPx - 0.5 : px > 0.5; +} + +/** + * Toggle springs the column width; the rail's `x` is `width - expanded` so the + * contents slide as a drawer instead of squashing. Drag-resize stays a snap. + * `minSize` is 0 while the spring is in flight — the panel group otherwise + * refuses any `resize()` below 12rem. + */ +function useSidebarDrawer( + open: boolean, + setOpen: (open: boolean) => void, + panelRef: RefObject, +) { + const reduceMotion = useReducedMotion() === true; + const laidOut = useRef(false); + const skip = useRef(false); + const flying = useRef(false); + const expanded = useMotionValue(256); + const width = useMotionValue(open ? 256 : 0); + const x = useTransform(() => width.get() - expanded.get()); + const sync = useReducer((n: number) => n + 1, 0)[1]; + const inFlight = sidebarDrawerInFlight(open, width.get(), expanded.get()); + + // Re-render when the spring or jump crosses the settle threshold so `inFlight` + // (and therefore `minSize` / fill) can be derived again. The effect that + // drives `animate` / `jump` must not `setState`. + useMotionValueEvent(width, "change", (value) => { + if (sidebarDrawerInFlight(open, value, expanded.get()) !== inFlight) { + sync(); + } + }); + + const minSize = open && !inFlight ? SIDEBAR_MIN_SIZE : 0; + + useEffect(() => { + const panel = panelRef.current; + if (panel === null || !laidOut.current) return; + + if (skip.current) { + skip.current = false; + width.jump(open ? expanded.get() : 0); + flying.current = false; + return; + } + + if (reduceMotion) { + width.jump(open ? expanded.get() : 0); + if (open) { + panel.expand(); + panel.resize(expanded.get()); + } else if (!panel.isCollapsed()) { + panel.collapse(); + } + flying.current = false; + return; + } + + flying.current = true; + const controls = animate(width, open ? expanded.get() : 0, { + onComplete() { + flying.current = false; + }, + onUpdate(value) { + if (value <= 0.5) { + if (!panel.isCollapsed()) panel.collapse(); + return; + } + panel.resize(value); + }, + }); + return () => { + flying.current = false; + controls.stop(); + }; + }, [expanded, open, panelRef, reduceMotion, width]); + + const onResize: OnPanelResize = (size) => { + const panel = panelRef.current; + if (!laidOut.current) { + laidOut.current = true; + if (size.inPixels > 0) { + expanded.set(size.inPixels); + if (open) width.set(size.inPixels); + } + if (panel === null) return; + if (!open && !panel.isCollapsed()) panel.collapse(); + if (open && panel.isCollapsed()) { + panel.expand(); + if (panel.isCollapsed()) panel.resize(expanded.get()); + } + return; + } + + if (flying.current) return; + + if (size.inPixels > 0) { + expanded.set(size.inPixels); + width.set(size.inPixels); + } + + const collapsed = size.inPixels === 0; + if (collapsed === open) { + skip.current = true; + setOpen(!collapsed); + } + }; + + return { + fill: open && !inFlight, + minSize, + onResize, + style: { width: expanded, x }, + }; +} + export function ShellSidebarPanel({ children }: { children: ReactNode }): ReactNode { const { open, setOpen } = useSidebar(); const panelRef = usePanelRef(); - const onResize = useCollapsedBinding( - panelRef, - !open, - (collapsed) => setOpen(!collapsed), - SIDEBAR_DEFAULT_SIZE, - ); + const drawer = useSidebarDrawer(open, setOpen, panelRef); return ( - {children} + + {children} + ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7bef96adc..e2e485116 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -368,6 +368,9 @@ importers: lucide-react: specifier: 'catalog:' version: 1.31.0(react@19.2.8) + motion: + specifier: ^13.1.1 + version: 13.1.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: specifier: ^19.2.8 version: 19.2.8 @@ -5939,6 +5942,17 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + framer-motion@13.1.1: + resolution: {integrity: sha512-B/xn2TPS4f61cEBLFjiYlQFnBZUW1YVj/LM+C+N4OP8Rs95VLEI2ot/RlfBg111la/EiyECFaJJi/A3FWA8MUA==} + peerDependencies: + react: ^19.2.8 + react-dom: ^19.2.8 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -6902,6 +6916,23 @@ packages: module-details-from-path@1.0.4: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + motion-dom@13.1.1: + resolution: {integrity: sha512-XSf8VYWSB6G/0IY3rWVbyLcxWXtAVHkN1PQE2agTaCv3u8RGvbwu56TyyR/MNzBqqNavEBTZzErcxI1TxBrjcA==} + + motion-utils@13.0.0: + resolution: {integrity: sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==} + + motion@13.1.1: + resolution: {integrity: sha512-WNZoK6xiF+kkTqkZ5K7FDDh6A8BG4i5Hc7KXtW8gtTxkpJFds+hIOrDaQGKjQj/AE/i4hJqAaUHEqp/Qo02y6Q==} + peerDependencies: + react: ^19.2.8 + react-dom: ^19.2.8 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -14012,6 +14043,15 @@ snapshots: forwarded@0.2.0: {} + framer-motion@13.1.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + motion-dom: 13.1.1 + motion-utils: 13.0.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + fresh@2.0.0: {} fs-extra@10.1.0: @@ -15197,6 +15237,20 @@ snapshots: module-details-from-path@1.0.4: {} + motion-dom@13.1.1: + dependencies: + motion-utils: 13.0.0 + + motion-utils@13.0.0: {} + + motion@13.1.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + framer-motion: 13.1.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + mri@1.2.0: {} mrmime@2.0.1: