├── .eslintrc.js
├── .github
└── workflows
│ └── test.yml
├── .gitignore
├── .husky
└── pre-commit
├── .node-version
├── .prettierrc
├── .storybook
├── main.ts
└── preview.ts
├── LICENSE
├── README.md
├── hero.gif
├── package.json
├── pnpm-lock.yaml
├── src
├── Resizable.tsx
├── constants.ts
├── index.ts
├── types.ts
└── useResizable.ts
├── stories
├── AxisX.stories.tsx
├── AxisXReverse.stories.tsx
├── AxisXReverseWithContainer.stories.tsx
├── AxisXWithContainer.stories.tsx
├── AxisY.stories.tsx
├── AxisYReverse.stories.tsx
├── AxisYReverseWithContainer.stories.tsx
├── AxisYWithContainer.stories.tsx
├── Callback.stories.tsx
├── Disabled.stories.tsx
├── DraggingState.stories.tsx
├── IdeClone.stories.tsx
├── VirtualSplitter.stories.tsx
├── components
│ ├── IdeClone.tsx
│ ├── SampleBox.tsx
│ └── SampleSeparator.tsx
├── style
│ ├── IdeClone.css
│ ├── SampleBox.css
│ └── SampleSeparator.css
└── utils
│ └── cn.ts
├── tsconfig.build.json
├── tsconfig.json
└── vite.config.ts
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: [
3 | 'airbnb',
4 | 'airbnb-typescript',
5 | 'airbnb/hooks',
6 | 'plugin:@typescript-eslint/recommended',
7 | 'plugin:@typescript-eslint/eslint-recommended',
8 | 'plugin:storybook/recommended',
9 | 'prettier',
10 | ],
11 | plugins: ['@typescript-eslint', 'jsx-a11y', 'react', 'react-hooks'],
12 | parser: '@typescript-eslint/parser',
13 | env: {
14 | browser: true,
15 | node: true,
16 | es6: true,
17 | },
18 | parserOptions: {
19 | sourceType: 'module',
20 | ecmaFeatures: {
21 | jsx: true,
22 | },
23 | project: './tsconfig.json',
24 | },
25 | settings: {
26 | react: {
27 | version: '18',
28 | },
29 | },
30 | rules: {
31 | 'react/prop-types': 'off',
32 | 'react/react-in-jsx-scope': 'off',
33 | 'react/jsx-no-target-blank': 2,
34 | 'react/jsx-props-no-spreading': 'off',
35 | 'react/jsx-sort-props': [
36 | 'error',
37 | {
38 | shorthandLast: true,
39 | callbacksLast: true,
40 | multiline: 'ignore',
41 | ignoreCase: false,
42 | noSortAlphabetically: true,
43 | reservedFirst: ['key', 'ref'],
44 | },
45 | ],
46 | 'react/function-component-definition': 'off',
47 | 'react/require-default-props': 'off',
48 | '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
49 | '@typescript-eslint/no-explicit-any': 'off',
50 | '@typescript-eslint/explicit-module-boundary-types': 'off',
51 | '@typescript-eslint/space-before-blocks': 'off',
52 | 'object-shorthand': ['error', 'properties'],
53 | 'no-console': [
54 | 'error',
55 | {
56 | allow: ['warn', 'error'],
57 | },
58 | ],
59 | 'no-restricted-exports': 'off',
60 | 'consistent-return': 'off',
61 | 'import/order': [
62 | 'error',
63 | {
64 | groups: [
65 | 'builtin',
66 | 'external',
67 | 'internal',
68 | ['parent', 'sibling'],
69 | 'object',
70 | 'index',
71 | 'type',
72 | ],
73 | 'newlines-between': 'always',
74 | alphabetize: { order: 'asc', caseInsensitive: true },
75 | pathGroups: [
76 | {
77 | pattern: '{react,react-dom}',
78 | group: 'builtin',
79 | position: 'before',
80 | },
81 | ],
82 | pathGroupsExcludedImportTypes: ['builtin', 'object'],
83 | },
84 | ],
85 | 'import/prefer-default-export': 'off',
86 | 'import/no-extraneous-dependencies': 'off',
87 | 'jsx-quotes': ['error', 'prefer-double'],
88 | },
89 | ignorePatterns: ['.eslintrc.*', 'vite.config.ts'],
90 | overrides: [
91 | {
92 | files: ['*.stories.tsx'],
93 | rules: {
94 | 'max-lines': 'off',
95 | 'no-console': 'off',
96 | 'no-alert': 'off',
97 | 'react-hooks/rules-of-hooks': 'off',
98 | },
99 | },
100 | ],
101 | };
102 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: test
2 | on:
3 | pull_request:
4 | types: [opened, synchronize]
5 | jobs:
6 | build:
7 | name: test
8 | runs-on: ubuntu-latest
9 | timeout-minutes: 30
10 | steps:
11 | - uses: actions/checkout@v4
12 | - name: Setup pnpm
13 | uses: pnpm/action-setup@v4
14 | with:
15 | version: 9
16 | - name: Use Node.js 20.x
17 | uses: actions/setup-node@v4
18 | with:
19 | node-version: 20
20 | cache: pnpm
21 | - run: pnpm install
22 | - run: pnpm test
23 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | lib
4 |
5 | # misc
6 | .DS_Store
7 | *.pem
8 |
9 | # debug
10 | npm-debug.log*
11 | yarn-debug.log*
12 | yarn-error.log*
13 |
14 | # local env files
15 | .env.local
16 | .env.development.local
17 | .env.test.local
18 | .env.production.local
19 | .env
20 |
21 | #idea
22 | .idea
23 |
24 | #build-storybook
25 | docs
26 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | pnpm lint-staged
5 |
--------------------------------------------------------------------------------
/.node-version:
--------------------------------------------------------------------------------
1 | 20.18.0
2 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 100,
3 | "tabWidth": 2,
4 | "useTabs": false,
5 | "semi": true,
6 | "singleQuote": true,
7 | "quoteProps": "as-needed",
8 | "jsxSingleQuote": false,
9 | "trailingComma": "all",
10 | "bracketSpacing": true,
11 | "bracketSameLine": false,
12 | "arrowParens": "always",
13 | "endOfLine": "lf"
14 | }
15 |
--------------------------------------------------------------------------------
/.storybook/main.ts:
--------------------------------------------------------------------------------
1 | import { mergeConfig } from 'vite';
2 |
3 | import type { StorybookConfig } from '@storybook/react-vite';
4 |
5 | const config: StorybookConfig = {
6 | stories: ['../stories/**/*.stories.@(ts|tsx)'],
7 | addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-storysource'],
8 | framework: '@storybook/react-vite',
9 | async viteFinal(config) {
10 | return mergeConfig(config, {});
11 | },
12 | };
13 |
14 | export default config;
15 |
--------------------------------------------------------------------------------
/.storybook/preview.ts:
--------------------------------------------------------------------------------
1 | import '../stories/style/SampleBox.css';
2 | import '../stories/style/SampleSeparator.css';
3 | import '../stories/style/IdeClone.css';
4 |
5 | import { Parameters } from '@storybook/react';
6 |
7 | export const parameters: Parameters = {
8 | layout: 'fullscreen',
9 | controls: {
10 | matchers: {
11 | color: /(background|color)$/i,
12 | date: /Date$/,
13 | },
14 | },
15 | };
16 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Ryo Sogawa
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-resizable-layout
2 |
3 | [](http://badge.fury.io/js/react-resizable-layout)
4 | [](LICENSE)
5 | [](https://bundlephobia.com/package/react-resizable-layout)
6 | [](https://bundlephobia.com/package/react-resizable-layout)
7 | [](https://bundlephobia.com/package/react-resizable-layout)
8 |
9 | A lightweight, accessible headless React component and hook for drag-and-drop resizable layouts.
10 |
11 | 
12 |
13 | [](https://codesandbox.io/s/react-resizable-layout-jy3vhk?fontsize=14&hidenavigation=1&theme=dark)
14 |
15 | [Storybook Demo](https://react-resizable-layout.vercel.app/)
16 |
17 | ## Features
18 |
19 | - 📦 Lightweight
20 | - 🕳 Headless
21 | - 🫶🏽 Accessible
22 | - 🤏 Drag and Drop Support
23 | - ⌨️ Keyboard Support
24 | - 🫙 Zero Dependencies
25 |
26 |
27 | ## Installation
28 |
29 | Install from npm:
30 |
31 | ```
32 | # Using npm
33 | npm install react-resizable-layout
34 |
35 | # Using Yarn
36 | yarn add react-resizable-layout
37 |
38 | # Using pnpm
39 | pnpm add react-resizable-layout
40 | ```
41 |
42 |
43 | ## Usage
44 | ### `Resizable` component
45 |
46 | ```tsx
47 | import Resizable from 'react-resizable-layout';
48 |
49 |
50 | {({ position, separatorProps }) => (
51 |
56 | )}
57 |
58 | ```
59 |
60 | ### `useResizable` hook
61 |
62 | ```tsx
63 | import { useResizable } from 'react-resizable-layout';
64 |
65 | const Component = () => {
66 | const { position, separatorProps } = useResizable({
67 | axis: 'x',
68 | })
69 |
70 | return (
71 |
76 | )
77 | }
78 | ```
79 |
80 |
81 | ## Aria Props
82 | The following attributes are added to `separatorProps` in accordance with W3C.
83 | https://www.w3.org/TR/wai-aria-1.2/#separator
84 |
85 | | Attribute | Value |
86 | |------------------|--------------------------------|
87 | | role | `'separator'` |
88 | | aria-valuenow | `position` |
89 | | aria-valuemin | `props.min` |
90 | | aria-valuemax | `props.max` |
91 | | aria-orientation | `'vertical'` or `'horizontal'` |
92 | | aria-disabled | `props.disabled` |
93 |
94 |
95 | ## Configuration
96 |
97 | ### Common Props
98 |
99 | | Name | Type | Default | Required | Description |
100 | |---------------|-------------------------|----------|----------|---------------------------------------------------------------------------|
101 | | axis | 'x' or 'y' | - | ◯ | Resize direction |
102 | | containerRef | ReactRef | - | - | Reference to container for calculating position |
103 | | disabled | boolean | false | - | Disable resize |
104 | | initial | number | 0 | - | Initial size |
105 | | min | number | 0 | - | Minimum size |
106 | | max | number | Infinity | - | Maximum size |
107 | | reverse | boolean | false | - | If true, returns position of the opposite side |
108 | | step | number | 10 | - | Pixel steps when operating with keyboard |
109 | | shiftStep | number | 50 | - | Pixel steps when operating with keyboard while holding down the shift key |
110 | | onResizeStart | function | - | - | Callback on resize start |
111 | | onResizeEnd | function | - | - | Callback on resize end |
112 |
113 | ### `Resizable` component children args
114 |
115 | `useResizable` hook returns same.
116 |
117 | | Name | Type | Description |
118 | |----------------|----------|----------------------------------------------------------------|
119 | | position | number | Separator's position (Width for 'x' axis, height for 'y' axis) |
120 | | endPosition | number | Separator's position at end of drag |
121 | | isDragging | boolean | True if dragging |
122 | | separatorProps | object | Separator's props like onPointerDown |
123 | | setPosition | function | Set separator's position |
124 |
125 |
126 | ## About keyboard support
127 | The following keyboard operations are supported.
128 |
129 | | Key | Operation |
130 | |-----------------------------------|---------------------------------------------|
131 | | Arrow (`↑`,`→`,`↓`,`←`) | Move the separator by 10px (default) |
132 | | `Shift` + Arrow (`↑`,`→`,`↓`,`←`) | Move the separator by 50px (default) |
133 | | `Enter` | Reset the separator to the initial position |
134 |
135 |
136 | ## About mouse support
137 | Double-click on the separator to return it to its initial position.
138 |
139 |
140 | ## Contribution
141 | Feel free to open an issue or make a pull request.
142 |
143 |
144 | ## License
145 | Distributed under the MIT License. See [LICENSE](./LICENSE) for more information.
146 |
--------------------------------------------------------------------------------
/hero.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RyoSogawa/react-resizable-layout/d8664d6185dd79d29ee7790e74d0c22be06c63c8/hero.gif
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-resizable-layout",
3 | "description": "Lightweight, accessible headless React component and hook for drag-and-drop resizable layouts.",
4 | "author": "RyoSogawa",
5 | "version": "0.7.2",
6 | "license": "MIT",
7 | "repository": {
8 | "type": "git",
9 | "url": "git+ssh://git@github.com/RyoSogawa/react-resizable-layout.git"
10 | },
11 | "bugs": {
12 | "url": "https://github.com/RyoSogawa/react-resizable-layout/issues"
13 | },
14 | "homepage": "https://github.com/RyoSogawa/react-resizable-layout",
15 | "keywords": [
16 | "react",
17 | "react-hooks",
18 | "react-component",
19 | "headless",
20 | "dnd",
21 | "resize",
22 | "typescript"
23 | ],
24 | "files": [
25 | "lib"
26 | ],
27 | "main": "./lib/index.js",
28 | "module": "./lib/index.module.js",
29 | "types": "./lib/index.d.ts",
30 | "scripts": {
31 | "prebuild": "rimraf ./lib",
32 | "build": "microbundle --jsx React.createElement --no-sourcemap --compress --tsconfig ./tsconfig.build.json",
33 | "format": "prettier --write \"(src|stories)/*.(js|ts|jsx|tsx)\"",
34 | "lint": "eslint ./src ./stories",
35 | "lint:fix": "pnpm lint --fix",
36 | "test": "tsc --noEmit && pnpm lint",
37 | "prepublishOnly": "pnpm test && pnpm build",
38 | "preversion": "pnpm lint",
39 | "version": "pnpm format && git add -A src",
40 | "postversion": "git push && git push --tags",
41 | "prepare": "husky install",
42 | "storybook": "storybook dev -p 6006",
43 | "build-storybook": "storybook build -o ./docs"
44 | },
45 | "peerDependencies": {
46 | "react": ">=17.0.0",
47 | "react-dom": ">=17.0.0"
48 | },
49 | "devDependencies": {
50 | "@storybook/addon-actions": "^8.4.5",
51 | "@storybook/addon-essentials": "^8.4.5",
52 | "@storybook/addon-links": "^8.4.5",
53 | "@storybook/addon-storysource": "^8.4.5",
54 | "@storybook/react": "^8.4.5",
55 | "@storybook/react-vite": "^8.4.5",
56 | "@storybook/test": "^8.4.5",
57 | "@types/react": "^18.3.12",
58 | "@types/react-dom": "^18.3.1",
59 | "@typescript-eslint/eslint-plugin": "^5.62.0",
60 | "@typescript-eslint/parser": "^5.62.0",
61 | "@vitejs/plugin-react": "^4.3.3",
62 | "babel-loader": "^8.4.1",
63 | "eslint": "^8.57.1",
64 | "eslint-config-airbnb": "^19.0.4",
65 | "eslint-config-airbnb-typescript": "^17.1.0",
66 | "eslint-config-prettier": "^8.10.0",
67 | "eslint-plugin-import": "^2.31.0",
68 | "eslint-plugin-jsx-a11y": "^6.10.2",
69 | "eslint-plugin-react": "^7.37.2",
70 | "eslint-plugin-react-hooks": "^4.6.2",
71 | "eslint-plugin-storybook": "0.9.0",
72 | "husky": "^7.0.0",
73 | "lint-staged": "^12.3.3",
74 | "microbundle": "^0.15.0",
75 | "prettier": "^2.8.8",
76 | "react": "^18.3.1",
77 | "react-dom": "^18.3.1",
78 | "rimraf": "^6.0.1",
79 | "storybook": "^8.4.5",
80 | "typescript": "^5.7.2",
81 | "vite": "^4.2.1"
82 | },
83 | "lint-staged": {
84 | "*.{js,ts,jsx,tsx}": [
85 | "eslint --fix",
86 | "prettier --write"
87 | ]
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/src/Resizable.tsx:
--------------------------------------------------------------------------------
1 | import useResizable from './useResizable';
2 |
3 | import type { ResizableProps } from './types';
4 |
5 | const Resizable = ({
6 | axis,
7 | disabled = false,
8 | initial = 0,
9 | min = 0,
10 | max = Infinity,
11 | reverse,
12 | onResizeStart,
13 | onResizeEnd,
14 | children,
15 | containerRef,
16 | }: ResizableProps): JSX.Element => {
17 | const resizable = useResizable({
18 | axis,
19 | disabled,
20 | initial,
21 | min,
22 | max,
23 | reverse,
24 | onResizeStart,
25 | onResizeEnd,
26 | containerRef,
27 | });
28 |
29 | return children(resizable);
30 | };
31 |
32 | export default Resizable;
33 |
--------------------------------------------------------------------------------
/src/constants.ts:
--------------------------------------------------------------------------------
1 | export const KEYS_LEFT = ['ArrowLeft', 'Left'];
2 | export const KEYS_RIGHT = ['ArrowRight', 'Right'];
3 | export const KEYS_UP = ['ArrowUp', 'Up'];
4 | export const KEYS_DOWN = ['ArrowDown', 'Down'];
5 | export const KEYS_AXIS_X = [...KEYS_LEFT, ...KEYS_RIGHT];
6 | export const KEYS_AXIS_Y = [...KEYS_UP, ...KEYS_DOWN];
7 | export const KEYS_POSITIVE = [...KEYS_RIGHT, ...KEYS_DOWN];
8 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import Resizable from './Resizable';
2 | import useResizable from './useResizable';
3 |
4 | export default Resizable;
5 | export { useResizable };
6 | export * from './types';
7 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import type React from 'react';
2 |
3 | export type SeparatorProps = React.ComponentPropsWithoutRef<'hr'>;
4 |
5 | /**
6 | * @deprecated Use SeparatorProps instead
7 | */
8 | export type SplitterProps = SeparatorProps;
9 |
10 | export type Resizable = {
11 | /**
12 | * border position
13 | */
14 | position: number;
15 | /**
16 | * position at end of drag
17 | */
18 | endPosition: number;
19 | /**
20 | * whether the border is dragging
21 | */
22 | isDragging: boolean;
23 | /**
24 | * props for drag bar
25 | */
26 | separatorProps: SeparatorProps;
27 | /**
28 | * set border position
29 | */
30 | setPosition: React.Dispatch>;
31 | /**
32 | * @deprecated Use separatorProps instead
33 | */
34 | splitterProps: SplitterProps;
35 | };
36 |
37 | export type ResizeCallbackArgs = {
38 | /**
39 | * position at the time of callback
40 | */
41 | position: number;
42 | };
43 |
44 | export type UseResizableProps = {
45 | /**
46 | * direction of resizing
47 | */
48 | axis: 'x' | 'y';
49 | /**
50 | * ref of the container element
51 | */
52 | containerRef?: React.RefObject;
53 | /**
54 | * if true, cannot resize
55 | */
56 | disabled?: boolean;
57 | /**
58 | * initial border position
59 | */
60 | initial?: number;
61 | /**
62 | * minimum border position
63 | */
64 | min?: number;
65 | /**
66 | * maximum border position
67 | */
68 | max?: number;
69 | /**
70 | * calculate border position from other side
71 | */
72 | reverse?: boolean;
73 | /**
74 | * resizing step with keyboard
75 | */
76 | step?: number;
77 | shiftStep?: number;
78 | /**
79 | * callback when border position changes start
80 | */
81 | onResizeStart?: (args: ResizeCallbackArgs) => void;
82 | /**
83 | * callback when border position changes end
84 | */
85 | onResizeEnd?: (args: ResizeCallbackArgs) => void;
86 | };
87 |
88 | export type ResizableProps = UseResizableProps & {
89 | /**
90 | * callback children
91 | */
92 | children: (props: Resizable) => JSX.Element;
93 | };
94 |
--------------------------------------------------------------------------------
/src/useResizable.ts:
--------------------------------------------------------------------------------
1 | import type React from 'react';
2 | import { useCallback, useMemo, useRef, useState } from 'react';
3 |
4 | import { KEYS_AXIS_X, KEYS_AXIS_Y, KEYS_POSITIVE } from './constants';
5 |
6 | import type { Resizable, SeparatorProps, UseResizableProps } from './types';
7 |
8 | const useResizable = ({
9 | axis,
10 | disabled = false,
11 | initial = 0,
12 | min = 0,
13 | max = Infinity,
14 | reverse,
15 | step = 10,
16 | shiftStep = 50,
17 | onResizeStart,
18 | onResizeEnd,
19 | containerRef,
20 | }: UseResizableProps): Resizable => {
21 | const initialPosition = Math.min(Math.max(initial, min), max);
22 | const isResizing = useRef(false);
23 | const [isDragging, setIsDragging] = useState(false);
24 | const [position, setPosition] = useState(initialPosition);
25 | const positionRef = useRef(initialPosition);
26 | const [endPosition, setEndPosition] = useState(initialPosition);
27 |
28 | const ariaProps = useMemo(
29 | () => ({
30 | role: 'separator',
31 | 'aria-valuenow': position,
32 | 'aria-valuemin': min,
33 | 'aria-valuemax': max,
34 | 'aria-orientation': axis === 'x' ? 'vertical' : 'horizontal',
35 | 'aria-disabled': disabled,
36 | }),
37 | [axis, disabled, max, min, position],
38 | );
39 |
40 | const handlePointermove = useCallback(
41 | (e: PointerEvent) => {
42 | // exit if not resizing
43 | if (!isResizing.current) return;
44 |
45 | if (disabled) return;
46 |
47 | e.stopPropagation();
48 | e.preventDefault(); // prevent text selection
49 |
50 | let currentPosition = (() => {
51 | if (axis === 'x') {
52 | if (containerRef?.current) {
53 | const containerNode = containerRef.current;
54 | const { left, width } = containerNode.getBoundingClientRect();
55 | return reverse ? left + width - e.clientX : e.clientX - left;
56 | }
57 | return reverse ? document.body.offsetWidth - e.clientX : e.clientX;
58 | }
59 | if (containerRef?.current) {
60 | const containerNode = containerRef.current;
61 | const { top, height } = containerNode.getBoundingClientRect();
62 | return reverse ? top + height - e.clientY : e.clientY - top;
63 | }
64 | return reverse ? document.body.offsetHeight - e.clientY : e.clientY;
65 | })();
66 |
67 | currentPosition = Math.min(Math.max(currentPosition, min), max);
68 | setPosition(currentPosition);
69 | positionRef.current = currentPosition;
70 | },
71 | [axis, disabled, max, min, reverse, containerRef],
72 | );
73 |
74 | const handlePointerup = useCallback(
75 | (e: PointerEvent) => {
76 | if (disabled) return;
77 |
78 | e.stopPropagation();
79 | isResizing.current = false;
80 | setIsDragging(false);
81 | setEndPosition(positionRef.current);
82 | document.removeEventListener('pointermove', handlePointermove);
83 | document.removeEventListener('pointerup', handlePointerup);
84 | if (onResizeEnd) onResizeEnd({ position: positionRef.current });
85 | },
86 | [disabled, handlePointermove, onResizeEnd],
87 | );
88 |
89 | const handlePointerdown = useCallback(
90 | (e) => {
91 | if (disabled) return;
92 |
93 | e.stopPropagation();
94 | isResizing.current = true;
95 | setIsDragging(true);
96 | document.addEventListener('pointermove', handlePointermove);
97 | document.addEventListener('pointerup', handlePointerup);
98 | if (onResizeStart) onResizeStart({ position: positionRef.current });
99 | },
100 | [disabled, handlePointermove, handlePointerup, onResizeStart],
101 | );
102 |
103 | const handleKeyDown = useCallback(
104 | (e) => {
105 | if (disabled) return;
106 |
107 | if (e.key === 'Enter') {
108 | setPosition(initial);
109 | positionRef.current = initial;
110 | return;
111 | }
112 | if (
113 | (axis === 'x' && !KEYS_AXIS_X.includes(e.key)) ||
114 | (axis === 'y' && !KEYS_AXIS_Y.includes(e.key))
115 | ) {
116 | return;
117 | }
118 |
119 | if (onResizeStart) onResizeStart({ position: positionRef.current });
120 |
121 | const changeStep = e.shiftKey ? shiftStep : step;
122 | const reversed = reverse ? -1 : 1;
123 | const dir = KEYS_POSITIVE.includes(e.key) ? reversed : -1 * reversed;
124 |
125 | const newPosition = position + changeStep * dir;
126 | if (newPosition < min) {
127 | setPosition(min);
128 | positionRef.current = min;
129 | } else if (newPosition > max) {
130 | setPosition(max);
131 | positionRef.current = max;
132 | } else {
133 | setPosition(newPosition);
134 | positionRef.current = newPosition;
135 | }
136 |
137 | if (onResizeEnd) onResizeEnd({ position: positionRef.current });
138 | },
139 | // prettier-ignore
140 | [disabled, axis, onResizeStart, shiftStep, step, reverse, position, min, max, onResizeEnd, initial],
141 | );
142 |
143 | const handleDoubleClick = useCallback(() => {
144 | if (disabled) return;
145 | setPosition(initial);
146 | positionRef.current = initial;
147 | }, [disabled, initial]);
148 |
149 | return {
150 | position,
151 | endPosition,
152 | isDragging,
153 | separatorProps: {
154 | ...ariaProps,
155 | onPointerDown: handlePointerdown,
156 | onKeyDown: handleKeyDown,
157 | onDoubleClick: handleDoubleClick,
158 | },
159 | setPosition,
160 | // deprecated. next version will remove this.
161 | splitterProps: {
162 | ...ariaProps,
163 | onPointerDown: handlePointerdown,
164 | onKeyDown: handleKeyDown,
165 | onDoubleClick: handleDoubleClick,
166 | },
167 | };
168 | };
169 |
170 | export default useResizable;
171 |
--------------------------------------------------------------------------------
/stories/AxisX.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { fn } from '@storybook/test';
4 |
5 | import SampleBox from './components/SampleBox';
6 | import SampleSeparator from './components/SampleSeparator';
7 | import Resizable from '../src/Resizable';
8 |
9 | import type { Meta, StoryObj } from '@storybook/react';
10 |
11 | export default {
12 | title: 'Basic',
13 | component: Resizable,
14 | args: {
15 | onResizeStart: fn(),
16 | onResizeEnd: fn(),
17 | },
18 | } as Meta;
19 |
20 | export const AxisX: StoryObj = {
21 | args: {
22 | axis: 'x',
23 | initial: 200,
24 | min: 100,
25 | max: 500,
26 | },
27 | render: (props) => (
28 |
29 | {({ position: x, separatorProps }) => (
30 |
31 |
32 |
33 |
34 |
35 | )}
36 |
37 | ),
38 | };
39 |
--------------------------------------------------------------------------------
/stories/AxisXReverse.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { fn } from '@storybook/test';
4 |
5 | import SampleBox from './components/SampleBox';
6 | import SampleSeparator from './components/SampleSeparator';
7 | import Resizable from '../src/Resizable';
8 |
9 | import type { Meta, StoryObj } from '@storybook/react';
10 |
11 | export default {
12 | title: 'Basic',
13 | component: Resizable,
14 | args: {
15 | onResizeStart: fn(),
16 | onResizeEnd: fn(),
17 | },
18 | } as Meta;
19 |
20 | export const AxisXReverse: StoryObj = {
21 | args: {
22 | axis: 'x',
23 | initial: 200,
24 | min: 100,
25 | max: 500,
26 | reverse: true,
27 | },
28 | render: (props) => (
29 |
30 | {({ position: x, separatorProps }) => (
31 |
32 |
33 |
34 |
35 |
36 | )}
37 |
38 | ),
39 | };
40 |
--------------------------------------------------------------------------------
/stories/AxisXReverseWithContainer.stories.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef } from 'react';
2 |
3 | import { fn } from '@storybook/test';
4 |
5 | import SampleBox from './components/SampleBox';
6 | import SampleSeparator from './components/SampleSeparator';
7 | import Resizable from '../src/Resizable';
8 |
9 | import type { Meta, StoryObj } from '@storybook/react';
10 |
11 | export default {
12 | title: 'WithContainer',
13 | component: Resizable,
14 | args: {
15 | onResizeStart: fn(),
16 | onResizeEnd: fn(),
17 | },
18 | } as Meta;
19 |
20 | export const AxisXReverseWithContainer: StoryObj = {
21 | args: {
22 | axis: 'x',
23 | initial: 200,
24 | min: 100,
25 | max: 500,
26 | reverse: true,
27 | },
28 | render: (props) => {
29 | const containerRef = useRef(null);
30 |
31 | return (
32 |
33 |
34 |
35 | {({ position: x, separatorProps }) => (
36 |
40 |
41 |
42 |
43 |
44 | )}
45 |
46 |
47 |
48 | );
49 | },
50 | };
51 |
--------------------------------------------------------------------------------
/stories/AxisXWithContainer.stories.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef } from 'react';
2 |
3 | import { fn } from '@storybook/test';
4 |
5 | import SampleBox from './components/SampleBox';
6 | import SampleSeparator from './components/SampleSeparator';
7 | import Resizable from '../src/Resizable';
8 |
9 | import type { Meta, StoryObj } from '@storybook/react';
10 |
11 | export default {
12 | title: 'WithContainer',
13 | component: Resizable,
14 | args: {
15 | onResizeStart: fn(),
16 | onResizeEnd: fn(),
17 | },
18 | } as Meta;
19 |
20 | export const AxisXWithContainer: StoryObj = {
21 | args: {
22 | axis: 'x',
23 | initial: 200,
24 | min: 100,
25 | max: 500,
26 | },
27 | render: (props) => {
28 | const containerRef = useRef(null);
29 |
30 | return (
31 |
32 |
33 |
34 | {({ position: x, separatorProps }) => (
35 |
40 |
41 |
42 |
43 |
44 | )}
45 |
46 |
47 |
48 | );
49 | },
50 | };
51 |
--------------------------------------------------------------------------------
/stories/AxisY.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { fn } from '@storybook/test';
4 |
5 | import SampleBox from './components/SampleBox';
6 | import SampleSeparator from './components/SampleSeparator';
7 | import Resizable from '../src/Resizable';
8 |
9 | import type { Meta, StoryObj } from '@storybook/react';
10 |
11 | export default {
12 | title: 'Basic',
13 | component: Resizable,
14 | args: {
15 | onResizeStart: fn(),
16 | onResizeEnd: fn(),
17 | },
18 | } as Meta;
19 |
20 | export const AxisY: StoryObj = {
21 | args: {
22 | axis: 'y',
23 | initial: 100,
24 | min: 50,
25 | max: 300,
26 | },
27 | render: (props) => (
28 |
29 | {({ position: y, separatorProps }) => (
30 |
38 |
39 |
40 |
41 |
42 | )}
43 |
44 | ),
45 | };
46 |
--------------------------------------------------------------------------------
/stories/AxisYReverse.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { fn } from '@storybook/test';
4 |
5 | import SampleBox from './components/SampleBox';
6 | import SampleSeparator from './components/SampleSeparator';
7 | import Resizable from '../src/Resizable';
8 |
9 | import type { Meta, StoryObj } from '@storybook/react';
10 |
11 | export default {
12 | title: 'Basic',
13 | component: Resizable,
14 | args: {
15 | onResizeStart: fn(),
16 | onResizeEnd: fn(),
17 | },
18 | } as Meta;
19 |
20 | export const AxisYReverse: StoryObj = {
21 | args: {
22 | axis: 'y',
23 | initial: 100,
24 | min: 50,
25 | max: 300,
26 | reverse: true,
27 | },
28 | render: (props) => (
29 |
30 | {({ position: y, separatorProps }) => (
31 |
39 |
40 |
41 |
42 |
43 | )}
44 |
45 | ),
46 | };
47 |
--------------------------------------------------------------------------------
/stories/AxisYReverseWithContainer.stories.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef } from 'react';
2 |
3 | import { fn } from '@storybook/test';
4 |
5 | import SampleBox from './components/SampleBox';
6 | import SampleSeparator from './components/SampleSeparator';
7 | import Resizable from '../src/Resizable';
8 |
9 | import type { Meta, StoryObj } from '@storybook/react';
10 |
11 | export default {
12 | title: 'WithContainer',
13 | component: Resizable,
14 | args: {
15 | onResizeStart: fn(),
16 | onResizeEnd: fn(),
17 | },
18 | } as Meta;
19 |
20 | export const AxisYReverseWithContainer: StoryObj = {
21 | args: {
22 | axis: 'y',
23 | initial: 100,
24 | min: 50,
25 | max: 300,
26 | reverse: true,
27 | },
28 | render: (props) => {
29 | const containerRef = useRef(null);
30 |
31 | return (
32 | <>
33 |
34 |
35 | {({ position: y, separatorProps }) => (
36 |
45 |
46 |
47 |
48 |
49 | )}
50 |
51 |
52 | >
53 | );
54 | },
55 | };
56 |
--------------------------------------------------------------------------------
/stories/AxisYWithContainer.stories.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef } from 'react';
2 |
3 | import { fn } from '@storybook/test';
4 |
5 | import SampleBox from './components/SampleBox';
6 | import SampleSeparator from './components/SampleSeparator';
7 | import Resizable from '../src/Resizable';
8 |
9 | import type { Meta, StoryObj } from '@storybook/react';
10 |
11 | export default {
12 | title: 'WithContainer',
13 | component: Resizable,
14 | args: {
15 | onResizeStart: fn(),
16 | onResizeEnd: fn(),
17 | },
18 | } as Meta;
19 |
20 | export const AxisYWithContainer: StoryObj = {
21 | args: {
22 | axis: 'y',
23 | initial: 100,
24 | min: 50,
25 | max: 300,
26 | },
27 | render: (props) => {
28 | const containerRef = useRef(null);
29 |
30 | return (
31 | <>
32 |
33 |
34 | {({ position: y, separatorProps }) => (
35 |
44 |
45 |
46 |
47 |
48 | )}
49 |
50 |
51 | >
52 | );
53 | },
54 | };
55 |
--------------------------------------------------------------------------------
/stories/Callback.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { fn } from '@storybook/test';
4 |
5 | import SampleBox from './components/SampleBox';
6 | import SampleSeparator from './components/SampleSeparator';
7 | import Resizable from '../src/Resizable';
8 |
9 | import type { Meta, StoryObj } from '@storybook/react';
10 |
11 | export default {
12 | title: 'Advanced',
13 | component: Resizable,
14 | args: {
15 | onResizeStart: fn(),
16 | onResizeEnd: fn(),
17 | },
18 | } as Meta;
19 |
20 | export const Callback: StoryObj = {
21 | args: {
22 | axis: 'x',
23 | initial: 200,
24 | },
25 | render: (props) => (
26 | {
29 | console.table(args);
30 | }}
31 | onResizeEnd={(args) => {
32 | console.table(args);
33 | }}
34 | >
35 | {({ position: x, separatorProps }) => (
36 |
37 |
44 |
45 |
46 |
47 | )}
48 |
49 | ),
50 | };
51 |
--------------------------------------------------------------------------------
/stories/Disabled.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { fn } from '@storybook/test';
4 |
5 | import SampleBox from './components/SampleBox';
6 | import SampleSeparator from './components/SampleSeparator';
7 | import Resizable from '../src/Resizable';
8 |
9 | import type { Meta, StoryObj } from '@storybook/react';
10 |
11 | export default {
12 | title: 'Basic',
13 | component: Resizable,
14 | args: {
15 | onResizeStart: fn(),
16 | onResizeEnd: fn(),
17 | },
18 | } as Meta;
19 |
20 | export const Disabled: StoryObj = {
21 | args: {
22 | axis: 'x',
23 | initial: 200,
24 | min: 100,
25 | max: 500,
26 | disabled: true,
27 | },
28 | render: (props) => (
29 |
30 | {({ position: x, separatorProps }) => (
31 |
32 |
33 |
34 |
35 |
36 | )}
37 |
38 | ),
39 | };
40 |
--------------------------------------------------------------------------------
/stories/DraggingState.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { fn } from '@storybook/test';
4 |
5 | import SampleBox from './components/SampleBox';
6 | import SampleSeparator from './components/SampleSeparator';
7 | import Resizable from '../src/Resizable';
8 |
9 | import type { Meta, StoryObj } from '@storybook/react';
10 |
11 | export default {
12 | title: 'Advanced',
13 | component: Resizable,
14 | args: {
15 | onResizeStart: fn(),
16 | onResizeEnd: fn(),
17 | },
18 | } as Meta;
19 |
20 | export const DraggingState: StoryObj = {
21 | args: {
22 | axis: 'x',
23 | initial: 200,
24 | min: 100,
25 | max: 500,
26 | },
27 | render: (props) => (
28 |
29 | {({ position: x, isDragging, separatorProps }) => (
30 |
31 |
38 |
39 |
45 |
46 | )}
47 |
48 | ),
49 | };
50 |
--------------------------------------------------------------------------------
/stories/IdeClone.stories.tsx:
--------------------------------------------------------------------------------
1 | import IdeClone from './components/IdeClone';
2 |
3 | import type { Meta, StoryObj } from '@storybook/react';
4 |
5 | export default {
6 | title: 'Example',
7 | component: IdeClone,
8 | } as Meta;
9 |
10 | export const IDEClone: StoryObj = {};
11 |
--------------------------------------------------------------------------------
/stories/VirtualSplitter.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { fn } from '@storybook/test';
4 |
5 | import SampleBox from './components/SampleBox';
6 | import SampleSeparator from './components/SampleSeparator';
7 | import Resizable from '../src/Resizable';
8 |
9 | import type { Meta, StoryObj } from '@storybook/react';
10 |
11 | export default {
12 | title: 'Advanced',
13 | component: Resizable,
14 | args: {
15 | onResizeStart: fn(),
16 | onResizeEnd: fn(),
17 | },
18 | } as Meta;
19 |
20 | export const VirtualSplitter: StoryObj = {
21 | args: {
22 | axis: 'x',
23 | initial: 200,
24 | min: 100,
25 | max: 500,
26 | },
27 | render: (props) => (
28 |
29 | {({ position: x, endPosition: endX, isDragging, separatorProps }) => (
30 |
39 |
40 |
41 |
52 |
53 |
54 | )}
55 |
56 | ),
57 | };
58 |
--------------------------------------------------------------------------------
/stories/components/IdeClone.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import SampleSeparator from './SampleSeparator';
4 | import { useResizable } from '../../src';
5 | import { cn } from '../utils/cn';
6 |
7 | const IdeClone = (): JSX.Element => {
8 | const {
9 | isDragging: isTerminalDragging,
10 | position: terminalH,
11 | separatorProps: terminalDragBarProps,
12 | } = useResizable({
13 | axis: 'y',
14 | initial: 150,
15 | min: 50,
16 | reverse: true,
17 | });
18 | const {
19 | isDragging: isFileDragging,
20 | position: fileW,
21 | separatorProps: fileDragBarProps,
22 | } = useResizable({
23 | axis: 'x',
24 | initial: 250,
25 | min: 50,
26 | });
27 | const {
28 | isDragging: isPluginDragging,
29 | position: pluginW,
30 | separatorProps: pluginDragBarProps,
31 | } = useResizable({
32 | axis: 'x',
33 | initial: 200,
34 | min: 50,
35 | reverse: true,
36 | });
37 |
38 | return (
39 |
40 |
41 |
45 | File Tree
46 |
47 |
48 |
49 |
Editor
50 |
51 |
55 | Plugin
56 |
57 |
58 |
59 |
60 |
64 | Terminal
65 |
66 |
67 | );
68 | };
69 |
70 | export default IdeClone;
71 |
--------------------------------------------------------------------------------
/stories/components/SampleBox.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { cn } from '../utils/cn';
4 |
5 | const SampleBox = ({ id, width, height, theme, size, text }: any) => {
6 | const additionalClass = (() => {
7 | switch (theme) {
8 | case 'blue':
9 | return 'sample-box--blue';
10 | case 'red':
11 | return 'sample-box--red';
12 | default:
13 | return null;
14 | }
15 | })();
16 |
17 | return (
18 |
27 | {text || 'Drag center bar to resize'}
28 |
29 | {size && `(currentSize : ${size}px)`}
30 |
31 | );
32 | };
33 |
34 | export default SampleBox;
35 |
--------------------------------------------------------------------------------
/stories/components/SampleSeparator.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 |
3 | import { cn } from '../utils/cn';
4 |
5 | const SampleSeparator = ({ id = 'drag-bar', dir, isDragging, disabled, ...props }: any) => {
6 | const [isFocused, setIsFocused] = useState(false);
7 |
8 | return (
9 |
setIsFocused(true)}
20 | onBlur={() => setIsFocused(false)}
21 | {...props}
22 | />
23 | );
24 | };
25 |
26 | export default SampleSeparator;
27 |
--------------------------------------------------------------------------------
/stories/style/IdeClone.css:
--------------------------------------------------------------------------------
1 | .flex {
2 | display: flex;
3 | }
4 |
5 | .flex-column {
6 | flex-direction: column;
7 | }
8 |
9 | .shrink-0 {
10 | flex-shrink: 0;
11 | }
12 |
13 | .grow {
14 | flex-grow: 1;
15 | }
16 |
17 | .h-screen {
18 | height: 100vh;
19 | }
20 |
21 | .bg-darker {
22 | background-color: #202020;
23 | }
24 |
25 | .bg-dark {
26 | background-color: #2D3032;
27 | }
28 |
29 | .font-mono {
30 | font-family: monospace;
31 | }
32 |
33 | .color-white {
34 | color: white;
35 | }
36 |
37 | .overflow-hidden {
38 | overflow: hidden;
39 | }
40 |
41 | .contents {
42 | display: grid;
43 | place-items: center;
44 | transition: filter 0.2s ease-out, background-color 0.2s ease-out;
45 | font-size: 16px;
46 | }
47 |
48 | .dragging {
49 | filter: blur(5px);
50 | background-color: #555555;
51 | }
52 |
--------------------------------------------------------------------------------
/stories/style/SampleBox.css:
--------------------------------------------------------------------------------
1 | .sample-box{
2 | display: grid;
3 | place-items: center;
4 | flex-shrink: 0;
5 | }
6 |
7 | .sample-box.sample-box--blue{
8 | background-color: #f0f9ff;
9 | }
10 |
11 | .sample-box.sample-box--red{
12 | background-color: #fff1f2;
13 | }
14 |
--------------------------------------------------------------------------------
/stories/style/SampleSeparator.css:
--------------------------------------------------------------------------------
1 | .sample-drag-bar {
2 | flex-shrink: 0;
3 | width: 5px;
4 | margin: 0;
5 | border: none;
6 | background-color: #d1d5db;
7 | cursor: col-resize;
8 | transition: background-color 0.15s 0.15s ease-in-out;
9 | }
10 |
11 | .sample-drag-bar.sample-drag-bar--dragging,
12 | .sample-drag-bar:hover{
13 | background-color: #63B3ED;
14 | }
15 |
16 | .sample-drag-bar.sample-drag-bar--horizontal {
17 | height: 5px;
18 | width: 100%;
19 | cursor: row-resize;
20 | }
21 |
22 | .disabled {
23 | cursor: not-allowed;
24 | }
25 | .disabled:hover {
26 | background-color: #d1d5db;
27 | }
28 |
--------------------------------------------------------------------------------
/stories/utils/cn.ts:
--------------------------------------------------------------------------------
1 | export const cn = (...args: any[]) => args.filter(Boolean).join(' ');
2 |
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "rootDir": "src"
5 | },
6 | "exclude": [
7 | "lib/**/*",
8 | "stories/**/*"
9 | ],
10 | "include": [
11 | "src/**/*"
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "module": "esnext",
5 | "strict": true,
6 | "skipLibCheck": true,
7 | "declaration": true,
8 | "declarationMap": true,
9 | "pretty": true,
10 | "newLine": "lf",
11 | "outDir": "lib",
12 | "allowJs": true,
13 | "rootDir": "/",
14 | "lib": ["es6", "dom", "es2016", "es2017"],
15 | "jsx": "react",
16 | "allowSyntheticDefaultImports": true,
17 | "moduleResolution": "Node"
18 | },
19 | "exclude": [
20 | "lib/**/*"
21 | ],
22 | "include": [
23 | "src/**/*",
24 | "stories/**/*"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import react from '@vitejs/plugin-react';
3 |
4 | export default defineConfig({
5 | plugins: [react()],
6 | });
7 |
--------------------------------------------------------------------------------