-
- {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 (
);
}
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: