Skip to content

Instantly share code, notes, and snippets.

@gkiely
Last active September 19, 2025 21:54
Show Gist options
  • Select an option

  • Save gkiely/afcfd1e79c161a3dbcb55ed64e6bae1e to your computer and use it in GitHub Desktop.

Select an option

Save gkiely/afcfd1e79c161a3dbcb55ed64e6bae1e to your computer and use it in GitHub Desktop.
Simple resize machine for a sidebar
import { assign, createMachine, fromCallback } from 'xstate';
type Context = {
key: string;
prevWidth: number;
width: number;
positionX: number;
defaultWidth: number;
};
type Events =
| React.PointerEvent<HTMLElement>
| React.MouseEvent<HTMLElement>
| { type: 'cancel' }
| { type: 'keydown' };
type Input = {
defaultWidth?: number;
key: string;
};
const isPointerEvent = (event: Events): event is React.PointerEvent<HTMLElement> & { target: HTMLElement } =>
event.type.startsWith('pointer') && 'target' in event;
const getScrollbarWidth = () => window.innerWidth - document.documentElement.clientWidth;
const resizeMachine = createMachine(
{
id: 'resize',
initial: 'idle',
types: {} as {
context: Context;
events: Events;
input: Input;
},
context: ({ input }) => ({
prevWidth: 0,
key: input.key ?? 'resizeMachine.width',
width: Number(localStorage.getItem(input.key)) || 0,
defaultWidth: input.defaultWidth ?? 0,
positionX: 0,
}),
states: {
idle: {
entry: [
({ context }) => {
localStorage.setItem(context.key, String(context.width));
},
assign({
positionX: ({ context }) => context.defaultWidth + context.width,
}),
],
on: {
pointerdown: {
target: 'dragging',
},
dblclick: {
target: 'idle',
actions: [
({ context }) => {
localStorage.setItem(context.key, String(0));
},
assign({
width: 0,
positionX: ({ context }) => context.defaultWidth,
}),
],
},
},
},
dragging: {
entry: [
'setPointerCapture',
assign({
prevWidth: ({ context }) => context.width,
}),
],
invoke: {
src: fromCallback(({ sendBack }) => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
sendBack({ type: 'cancel' });
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}),
},
on: {
pointermove: {
actions: ['updateWidth'],
},
pointerup: {
target: 'idle',
actions: ['releasePointerCapture'],
},
pointercancel: {
target: 'idle',
actions: ['releasePointerCapture'],
},
pointerout: {
target: 'idle',
actions: ['releasePointerCapture'],
},
cancel: {
target: 'idle',
actions: [
assign({
width: ({ context }) => context.prevWidth,
}),
],
},
},
},
},
},
{
actions: {
updateWidth: assign({
width: ({ context, event }) => {
if (!isPointerEvent(event)) return context.width;
const rect = event.target.getBoundingClientRect();
const { left, width } = rect;
const desiredWidth =
event.clientX - left + context.prevWidth - (context.positionX === context.defaultWidth ? 0 : width / 2);
const innerWidth = window.innerWidth - getScrollbarWidth();
const calculatedWidth =
desiredWidth + context.defaultWidth > innerWidth
? innerWidth - context.defaultWidth + width / 2
: desiredWidth;
if (calculatedWidth + context.defaultWidth < 0) return -context.defaultWidth;
return calculatedWidth;
},
}),
releasePointerCapture: ({ event }) => {
if (!isPointerEvent(event)) return;
event.target.releasePointerCapture(event.pointerId);
},
setPointerCapture: ({ event }) => {
if (!isPointerEvent(event)) return;
event.target.setPointerCapture(event.pointerId);
},
},
}
);
export default resizeMachine;
import type { ActorRefFrom } from 'xstate';
import type resizeMachine from '~/utils/machines/resizemachine';
export const defaultWidth = 350;
const sliderWidth = 10;
const zIndex = 2;
const base = {
position: 'fixed',
top: 0,
bottom: 0,
left: defaultWidth - sliderWidth / 2,
width: sliderWidth,
userSelect: 'none',
} satisfies React.CSSProperties;
const styles = {
handle: {
...base,
zIndex,
cursor: 'col-resize',
} satisfies React.CSSProperties,
slider: {
...base,
zIndex: zIndex - 1,
} satisfies React.CSSProperties
};
export const Resizer = ({
positionX,
width,
send,
defaultWidth,
sliderWidth,
}: {
positionX: number;
width: number;
send: ActorRefFrom<typeof resizeMachine>['send'];
defaultWidth: number;
sliderWidth: number;
}) => {
const left = defaultWidth - sliderWidth / 2 + (width ? width - sliderWidth / 2 : 0);
const handleLeft = defaultWidth === positionX ? defaultWidth - sliderWidth / 2 : positionX - sliderWidth;
const maxLeft = -sliderWidth / 2;
return (
<>
<div
style={{ ...styles.handle, left: handleLeft < 0 ? maxLeft : handleLeft }}
onPointerDown={send}
onPointerMove={send}
onPointerUp={send}
onPointerCancel={send}
onPointerOut={send}
onDoubleClick={send}
/>
<div style={{ ...styles.slider, left: left < 0 ? maxLeft : left }} />
</>
);
};
import { useMachine } from '@xstate/react';
import resizeMachine from '~/utils/machines/resizemachine';
import { Resizer, defaultWidth, sliderWidth } from './Resizer';
export const Sidebar = () => {
const [resizeState, sendToResize] = useMachine(resizeMachine, {
input: {
key: 'sidebar-width',
defaultWidth,
},
});
const width =
resizeState.context.width === 0
? resizeState.context.defaultWidth + resizeState.context.width
: resizeState.context.defaultWidth + resizeState.context.width - sliderWidth / 2;
return <div style={{ width, height: '100%' }}>
<div style={{ width: '100%', height: '100%' }}>My sidebar content</div>
<Resizer
positionX={resizeState.context.positionX}
width={resizeState.context.width}
send={sendToResize}
defaultWidth={defaultWidth}
sliderWidth={sliderWidth}
/>
</nav>
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment