diff --git a/.changeset/slimy-walls-yawn.md b/.changeset/slimy-walls-yawn.md
new file mode 100644
index 0000000..85e066d
--- /dev/null
+++ b/.changeset/slimy-walls-yawn.md
@@ -0,0 +1,38 @@
+---
+'react-collapsed': major
+---
+
+This is a big refactor of `react-collapsed`, enough I wanted to denote it with a new major version.
+
+## BREAKING CHANGES
+
+- `expandStyles` and `collapseStyles` options have been removed.
+- `onExpandStart`, `onExpandEnd`, `onCollapseStart`, `onCollapseEnd` options have been removed and replaced with `onTransitionStateChange`:
+
+  ```typescript
+  const useCollapse({
+    onTransitionStateChange(stage) {
+      switch (stage) {
+        case 'expandStart':
+        case 'expandEnd':
+        case 'expanding':
+        case 'collapseStart':
+        case 'collapseEnd':
+        case 'collapsing':
+          // do thing
+        default:
+          break;
+      }
+    }
+  })
+  ```
+
+## Other changes
+
+- Unique IDs for accessibility are now generated with `React.useId` if it's available.
+- Styles assigned to the collapse element are now assigned to the DOM element directly via a ref, and no longer require `ReactDOM.flushSync` to update styles in transition.
+- Added `role="region"` to collapse.
+- Added logic to handle disabling the animation if the user has the prefers reduced motion setting enabled.
+- Replaced animation resolution handling to a programmatic timer, instead of the `'transitionend'` event. Should fix #103.
+- Improved the types for `getCollapseProps` and `getToggleProps`, so their arguments and return type is more accurately typed. This should help catch cases when props returned by the getters are duplicated on the component (such as `ref` or `style`).
+- Changes the props returned by `getToggleProps` depending on the HTML tag of the component.
diff --git a/.npmrc b/.npmrc
index b185203..a3491e0 100644
--- a/.npmrc
+++ b/.npmrc
@@ -1 +1,2 @@
 registry = https://registry.npmjs.org
+public-hoist-pattern[]=*storybook*
diff --git a/README.md b/README.md
deleted file mode 100644
index c601c33..0000000
--- a/README.md
+++ /dev/null
@@ -1,19 +0,0 @@
-# Collapsed ๐Ÿ™ˆ
-
-[![CI][ci-badge]][ci]
-[![Netlify][netlify-badge]][netlify]
-
-A framework-agnostic utility for creating accessible expand/collapse elements. Animates the height using CSS transitions from `0` to `auto`.
-
-## Features
-
-- Handles the height of animations of your elements, `auto` included!
-- Headless - bring your own UI!
-- Accessible out of the box - no need to worry if your collapse/expand component is accessible, since this takes care of it for you!
-- No animation framework required! Simply powered by CSS animations
-- Written in TypeScript
-
-[ci-badge]: https://img.shields.io/github/actions/workflow/status/roginfarrer/react-collapsed/main.yml
-[ci]: https://github.com/roginfarrer/react-collapsed/actions?query=workflow%3ACI+branch%3Amain
-[netlify]: https://app.netlify.com/sites/react-collapsed/deploys
-[netlify-badge]: https://img.shields.io/netlify/5a5b0e80-d15e-4983-976d-37fe6bdada7a
diff --git a/README.md b/README.md
new file mode 120000
index 0000000..9efceaf
--- /dev/null
+++ b/README.md
@@ -0,0 +1 @@
+packages/react-collapsed/README.md
\ No newline at end of file
diff --git a/package.json b/package.json
index 25d8800..cc18a5c 100644
--- a/package.json
+++ b/package.json
@@ -16,13 +16,14 @@
     "@changesets/cli": "^2.25.2",
     "@collapsed-internal/tsconfig": "workspace:*",
     "@types/node": "^16.7.13",
+    "buffer": "^5.5.0",
+    "eslint": "^7.32.0",
     "eslint-config-collapsed": "workspace:*",
     "np": "^6.4.0",
     "prettier": "^2.3.2",
+    "process": "^0.11.10",
     "turbo": "^1.6.3",
-    "typescript": "^4.9",
-    "buffer": "^5.5.0",
-    "process": "^0.11.10"
+    "typescript": "^4.9"
   },
   "prettier": "eslint-config-rogin/prettier",
   "alias": {
diff --git a/packages/core/README.md b/packages/core/README.md
index fced911..18b2415 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -1,3 +1,9 @@
+# NOTE
+
+You're probably looking for [react-collapsed](../react-collapsed). This package (alongside [@collapsed/react](../react)) is a WIP rewrite to create a Vanilla JS core.
+
+---
+
 ## @collapsed/core
 
 [![CI][ci-badge]][ci]
diff --git a/packages/react-collapsed/.eslintignore b/packages/react-collapsed/.eslintignore
new file mode 100644
index 0000000..de4d1f0
--- /dev/null
+++ b/packages/react-collapsed/.eslintignore
@@ -0,0 +1,2 @@
+dist
+node_modules
diff --git a/packages/react-collapsed/.eslintrc.js b/packages/react-collapsed/.eslintrc.js
new file mode 100644
index 0000000..3b06504
--- /dev/null
+++ b/packages/react-collapsed/.eslintrc.js
@@ -0,0 +1,3 @@
+module.exports = {
+  extends: ['collapsed', 'collapsed/react'],
+}
diff --git a/packages/react-collapsed/.storybook/main.js b/packages/react-collapsed/.storybook/main.js
new file mode 100644
index 0000000..16782f2
--- /dev/null
+++ b/packages/react-collapsed/.storybook/main.js
@@ -0,0 +1,11 @@
+module.exports = {
+  stories: ['../stories/**/*.stories.@(js|jsx|ts|tsx)'],
+  addons: [
+    '@storybook/addon-links',
+    '@storybook/addon-essentials',
+    '@storybook/addon-a11y',
+  ],
+  core: {
+    builder: '@storybook/builder-vite',
+  },
+}
diff --git a/packages/react-collapsed/.storybook/preview.js b/packages/react-collapsed/.storybook/preview.js
new file mode 100644
index 0000000..48afd56
--- /dev/null
+++ b/packages/react-collapsed/.storybook/preview.js
@@ -0,0 +1,9 @@
+export const parameters = {
+  actions: { argTypesRegex: "^on[A-Z].*" },
+  controls: {
+    matchers: {
+      color: /(background|color)$/i,
+      date: /Date$/,
+    },
+  },
+}
\ No newline at end of file
diff --git a/packages/react-collapsed/CHANGELOG.md b/packages/react-collapsed/CHANGELOG.md
new file mode 100644
index 0000000..1ffd572
--- /dev/null
+++ b/packages/react-collapsed/CHANGELOG.md
@@ -0,0 +1,47 @@
+Changelog has been moved to [the releases tab](https://github.com/roginfarrer/react-collapsed/releases).
+
+# 2.0.0
+
+Complete rewrite using React hooks!
+
+- Ends support for React versions < 16.8.x
+- Library now exports a custom hook in lieu of a render prop component
+- Adds support for unmounting the contents of the Collapse element when closed
+
+```js
+import React from 'react'
+import useCollapse from 'react-collapsed'
+
+function Demo() {
+  const { getCollapseProps, getToggleProps, isOpen } = useCollapse()
+
+  return (
+    <>
+      <button {...getToggleProps()}>{isOpen ? 'Collapse' : 'Expand'}</button>
+      <section {...getCollapseProps()}>Collapsed content ๐Ÿ™ˆ</section>
+    </>
+  )
+}
+```
+
+# 1.0.0
+
+Bumped to full release! :)
+
+- `duration`, `easing`, and `delay` now support taking an object with `in` and `out` keys to configure differing in-and-out transitions
+
+# 0.2.0
+
+### Breaking Changes
+
+- `getCollapsibleProps` => `getCollapseProps`. Renamed since it's easier to spell ๐Ÿ˜…
+
+### Other
+
+- Slew of Flow bug fixes
+- Improved documentation
+
+# 0.1.3
+
+- ESLINT wasn't working properly - fixed this
+- Added `files` key to package.json to improve NPM load
diff --git a/packages/react-collapsed/README.md b/packages/react-collapsed/README.md
new file mode 100644
index 0000000..8794039
--- /dev/null
+++ b/packages/react-collapsed/README.md
@@ -0,0 +1,150 @@
+# ๐Ÿ™ˆ react-collapsed (useCollapse)
+
+[![CI][ci-badge]][ci]
+![npm bundle size (version)][minzipped-badge]
+[![npm version][npm-badge]][npm-version]
+[![Netlify Status](https://api.netlify.com/api/v1/badges/5a5b0e80-d15e-4983-976d-37fe6bdada7a/deploy-status)](https://app.netlify.com/sites/react-collapsed/deploys)
+
+A React hook for creating accessible expand/collapse components. Animates the height using CSS transitions from `0` to `auto`.
+
+## Features
+
+- Handles the height of animations of your elements, `auto` included!
+- You control the UI - `useCollapse` provides the necessary props, you control the styles and the elements.
+- Accessible out of the box - no need to worry if your collapse/expand component is accessible, since this takes care of it for you!
+- No animation framework required! Simply powered by CSS animations
+- Written in TypeScript
+
+## Demo
+
+[See the demo site!](https://react-collapsed.netlify.app/)
+
+[CodeSandbox demo](https://codesandbox.io/s/magical-browser-vibv2?file=/src/App.tsx)
+
+## Installation
+
+```bash
+$ npm i react-collapsed
+```
+
+## Usage
+
+### Simple Usage
+
+```js
+import React from 'react'
+import { useCollapse } from 'react-collapsed'
+
+function Demo() {
+  const { getCollapseProps, getToggleProps, isExpanded } = useCollapse()
+
+  return (
+    <div>
+      <button {...getToggleProps()}>
+        {isExpanded ? 'Collapse' : 'Expand'}
+      </button>
+      <section {...getCollapseProps()}>Collapsed content ๐Ÿ™ˆ</section>
+    </div>
+  )
+}
+```
+
+### Control it yourself
+
+```js
+import React, { useState } from 'react'
+import { useCollapse } from 'react-collapsed'
+
+function Demo() {
+  const [isExpanded, setExpanded] = useState(false)
+  const { getCollapseProps, getToggleProps } = useCollapse({ isExpanded })
+
+  return (
+    <div>
+      <button
+        {...getToggleProps({
+          onClick: () => setExpanded((prevExpanded) => !prevExpanded),
+        })}
+      >
+        {isExpanded ? 'Collapse' : 'Expand'}
+      </button>
+      <section {...getCollapseProps()}>Collapsed content ๐Ÿ™ˆ</section>
+    </div>
+  )
+}
+```
+
+## API
+
+```js
+const { getCollapseProps, getToggleProps, isExpanded, setExpanded } =
+  useCollapse({
+    isExpanded: boolean,
+    defaultExpanded: boolean,
+    collapsedHeight: 0,
+    easing: string,
+    duration: number,
+    onTransitionStateChange: func,
+  })
+```
+
+### `useCollapse` Config
+
+The following are optional properties passed into `useCollapse({ })`:
+
+| Prop                    | Type     | Default                        | Description                                                                                                                                         |
+| ----------------------- | -------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
+| isExpanded              | boolean  | `undefined`                    | If true, the Collapse is expanded                                                                                                                   |
+| defaultExpanded         | boolean  | `false`                        | If true, the Collapse will be expanded when mounted                                                                                                 |
+| collapsedHeight         | number   | `0`                            | The height of the content when collapsed                                                                                                            |
+| easing                  | string   | `cubic-bezier(0.4, 0, 0.2, 1)` | The transition timing function for the animation                                                                                                    |
+| duration                | number   | `undefined`                    | The duration of the animation in milliseconds. By default, the duration is programmatically calculated based on the height of the collapsed element |
+| onTransitionStateChange | function | no-op                          | Handler called with at each stage of the transition animation                                                                                       |
+| hasDisabledAnimation    | boolean  | false                          | If true, will disable the animation                                                                                                                 |
+
+### What you get
+
+| Name             | Description                                                                                                 |
+| ---------------- | ----------------------------------------------------------------------------------------------------------- |
+| getCollapseProps | Function that returns a prop object, which should be spread onto the collapse element                       |
+| getToggleProps   | Function that returns a prop object, which should be spread onto an element that toggles the collapse panel |
+| isExpanded       | Whether or not the collapse is expanded (if not controlled)                                                 |
+| setExpanded      | Sets the hook's internal isExpanded state                                                                   |
+
+## Alternative Solutions
+
+- [react-spring](https://www.react-spring.io/) - JavaScript animation based library that can potentially have smoother animations. Requires a bit more work to create an accessible collapse component.
+- [react-animate-height](https://github.com/Stanko/react-animate-height/) - Another library that uses CSS transitions to animate to any height. It provides components, not a hook.
+
+## FAQ
+
+<details>
+<summary>When I apply vertical <code>padding</code> to the component that gets <code>getCollapseProps</code>, the animation is janky and it doesn't collapse all the way. What gives?</summary>
+
+The collapse works by manipulating the `height` property. If an element has vertical padding, that padding expandes the size of the element, even if it has `height: 0; overflow: hidden`.
+
+To avoid this, simply move that padding from the element to an element directly nested within in.
+
+```javascript
+// from
+<div {...getCollapseProps({style: {padding: 20}})}
+  This will do weird things
+</div>
+
+// to
+<div {...getCollapseProps()}
+  <div style={{padding: 20}}>
+    Much better!
+  </div>
+</div>
+```
+
+</details>
+
+[minzipped-badge]: https://img.shields.io/bundlephobia/minzip/react-collapsed/latest
+[npm-badge]: http://img.shields.io/npm/v/react-collapsed.svg?style=flat
+[npm-version]: https://npmjs.org/package/react-collapsed 'View this project on npm'
+[ci-badge]: https://github.com/roginfarrer/collapsed/workflows/CI/badge.svg
+[ci]: https://github.com/roginfarrer/collapsed/actions?query=workflow%3ACI+branch%3Amain
+[netlify]: https://app.netlify.com/sites/react-collapsed/deploys
+[netlify-badge]: https://api.netlify.com/api/v1/badges/4d285ffc-aa4f-4d32-8549-eb58e00dd2d1/deploy-status
diff --git a/packages/react-collapsed/cypress.config.ts b/packages/react-collapsed/cypress.config.ts
new file mode 100644
index 0000000..0e1d0e3
--- /dev/null
+++ b/packages/react-collapsed/cypress.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from 'cypress'
+
+export default defineConfig({
+  component: {
+    devServer: {
+      framework: 'react',
+      bundler: 'vite',
+    },
+  },
+  video: false,
+  screenshotOnRunFailure: false,
+})
diff --git a/packages/react-collapsed/cypress/component/Controlled.cy.tsx b/packages/react-collapsed/cypress/component/Controlled.cy.tsx
new file mode 100644
index 0000000..9e135a8
--- /dev/null
+++ b/packages/react-collapsed/cypress/component/Controlled.cy.tsx
@@ -0,0 +1,50 @@
+import * as React from 'react'
+import { useCollapse } from '../../src'
+
+const Collapse = React.forwardRef<
+  HTMLDivElement,
+  React.ComponentPropsWithoutRef<'div'>
+>(function Collapse(props, ref) {
+  return (
+    <div {...props} ref={ref} data-testid="collapse">
+      <div
+        style={{
+          height: 300,
+          border: '2px solid red',
+          backgroundColor: 'lightblue',
+        }}
+      >
+        helloooo
+      </div>
+    </div>
+  )
+})
+
+const Controlled = () => {
+  const [isExpanded, setOpen] = React.useState<boolean>(true)
+  const { getToggleProps, getCollapseProps } = useCollapse({
+    isExpanded,
+  })
+
+  return (
+    <div>
+      <button {...getToggleProps({ onClick: () => setOpen((x) => !x) })}>
+        {isExpanded ? 'Close' : 'Open'}
+      </button>
+      <Collapse {...getCollapseProps()} />
+    </div>
+  )
+}
+
+describe('Controlled', () => {
+  it('playground', () => {
+    cy.mount(<Controlled />)
+
+    // getToggleProps
+    cy.get('button').should('have.text', 'Close')
+    cy.get('[data-testid="collapse"]').should('be.visible')
+    cy.get('button').click()
+    cy.get('button').should('have.text', 'Open')
+    cy.get('[data-testid="collapse"]').should('not.be.visible')
+  })
+})
diff --git a/packages/react-collapsed/cypress/component/Uncontrolled.cy.tsx b/packages/react-collapsed/cypress/component/Uncontrolled.cy.tsx
new file mode 100644
index 0000000..50922e0
--- /dev/null
+++ b/packages/react-collapsed/cypress/component/Uncontrolled.cy.tsx
@@ -0,0 +1,57 @@
+/* eslint-disable jsx-a11y/no-static-element-interactions */
+/* eslint-disable jsx-a11y/click-events-have-key-events */
+import * as React from 'react'
+import { useCollapse } from '../../src'
+
+const Collapse = React.forwardRef<
+  HTMLDivElement,
+  React.ComponentPropsWithoutRef<'div'>
+>(function Collapse(props, ref) {
+  return (
+    <div {...props} ref={ref} data-testid="collapse">
+      <div
+        style={{
+          height: 300,
+          border: '2px solid red',
+          backgroundColor: 'lightblue',
+        }}
+      >
+        helloooo
+      </div>
+    </div>
+  )
+})
+
+const Uncontrolled = () => {
+  const { getToggleProps, getCollapseProps, isExpanded, setExpanded } =
+    useCollapse()
+
+  return (
+    <div>
+      <button {...getToggleProps()}>{isExpanded ? 'Close' : 'Open'}</button>
+      <div onClick={() => setExpanded((n) => !n)} data-testid="alt">
+        Alt
+      </div>
+      <Collapse {...getCollapseProps()} />
+    </div>
+  )
+}
+
+describe('Uncontrolled', () => {
+  it('opens and closes', () => {
+    cy.mount(<Uncontrolled />)
+
+    // getToggleProps
+    cy.get('button').should('have.text', 'Open')
+    cy.get('[data-testid="collapse"]').should('not.be.visible')
+    cy.get('button').click()
+    cy.get('button').should('have.text', 'Close')
+    cy.get('[data-testid="collapse"]').should('be.visible')
+
+    // setExpanded
+    cy.get('[data-testid="alt"]').click()
+    cy.get('[data-testid="collapse"]').should('not.be.visible')
+    cy.get('[data-testid="alt"]').click()
+    cy.get('[data-testid="collapse"]').should('be.visible')
+  })
+})
diff --git a/packages/react-collapsed/cypress/fixtures/example.json b/packages/react-collapsed/cypress/fixtures/example.json
new file mode 100644
index 0000000..02e4254
--- /dev/null
+++ b/packages/react-collapsed/cypress/fixtures/example.json
@@ -0,0 +1,5 @@
+{
+  "name": "Using fixtures to represent data",
+  "email": "hello@cypress.io",
+  "body": "Fixtures are a great way to mock data for responses to routes"
+}
diff --git a/packages/react-collapsed/cypress/support/commands.ts b/packages/react-collapsed/cypress/support/commands.ts
new file mode 100644
index 0000000..bc98b46
--- /dev/null
+++ b/packages/react-collapsed/cypress/support/commands.ts
@@ -0,0 +1,38 @@
+/// <reference types="cypress" />
+// ***********************************************
+// This example commands.ts shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
+//
+// declare global {
+//   namespace Cypress {
+//     interface Chainable {
+//       login(email: string, password: string): Chainable<void>
+//       drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
+//       dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
+//       visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
+//     }
+//   }
+// }
+import '@testing-library/cypress/add-commands'
diff --git a/packages/react-collapsed/cypress/support/component-index.html b/packages/react-collapsed/cypress/support/component-index.html
new file mode 100644
index 0000000..ac6e79f
--- /dev/null
+++ b/packages/react-collapsed/cypress/support/component-index.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width,initial-scale=1.0">
+    <title>Components App</title>
+  </head>
+  <body>
+    <div data-cy-root></div>
+  </body>
+</html>
\ No newline at end of file
diff --git a/packages/react-collapsed/cypress/support/component.ts b/packages/react-collapsed/cypress/support/component.ts
new file mode 100644
index 0000000..26e8d74
--- /dev/null
+++ b/packages/react-collapsed/cypress/support/component.ts
@@ -0,0 +1,40 @@
+/* eslint-disable @typescript-eslint/no-namespace */
+// ***********************************************************
+// This example support/component.ts is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
+
+import { mount } from 'cypress/react18'
+
+// Augment the Cypress namespace to include type definitions for
+// your custom command.
+// Alternatively, can be defined in cypress/support/component.d.ts
+// with a <reference path="./component" /> at the top of your spec.
+declare global {
+  namespace Cypress {
+    interface Chainable {
+      mount: typeof mount
+    }
+  }
+}
+
+Cypress.Commands.add('mount', mount)
+
+// Example use:
+// cy.mount(<MyComponent />)
diff --git a/packages/react-collapsed/cypress/tsconfig.json b/packages/react-collapsed/cypress/tsconfig.json
new file mode 100644
index 0000000..6dfb626
--- /dev/null
+++ b/packages/react-collapsed/cypress/tsconfig.json
@@ -0,0 +1,8 @@
+{
+  "extends": "../tsconfig.json",
+  "compilerOptions": {
+    "noEmit": true,
+    "types": ["cypress", "react", "node"]
+  },
+  "include": ["../node_modules/cypress", "./**/*.ts"]
+}
diff --git a/packages/react-collapsed/jest-setup.ts b/packages/react-collapsed/jest-setup.ts
new file mode 100644
index 0000000..c44951a
--- /dev/null
+++ b/packages/react-collapsed/jest-setup.ts
@@ -0,0 +1 @@
+import '@testing-library/jest-dom'
diff --git a/packages/react-collapsed/jest.config.js b/packages/react-collapsed/jest.config.js
new file mode 100644
index 0000000..3947bce
--- /dev/null
+++ b/packages/react-collapsed/jest.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+  preset: 'ts-jest',
+  testEnvironment: 'jsdom',
+  setupFilesAfterEnv: ['<rootDir>/jest-setup.ts'],
+  testMatch: ['<rootDir>/**/*.(spec|test).{ts,tsx,js,jsx}'],
+}
diff --git a/packages/react-collapsed/package.json b/packages/react-collapsed/package.json
new file mode 100644
index 0000000..70083d7
--- /dev/null
+++ b/packages/react-collapsed/package.json
@@ -0,0 +1,92 @@
+{
+  "name": "react-collapsed",
+  "version": "3.8.0",
+  "author": "Rogin Farrer <rogin@roginfarrer.com>",
+  "description": "A React custom-hook for creating flexible and accessible expand/collapse components.",
+  "license": "MIT",
+  "source": "src/index.ts",
+  "main": "dist/index.cjs.js",
+  "module": "dist/index.mjs",
+  "types": "dist/index.d.ts",
+  "files": [
+    "dist"
+  ],
+  "scripts": {
+    "build": "tsup",
+    "watch": "tsup --watch",
+    "test": "jest",
+    "typecheck": "tsc --project tsconfig.json --noEmit",
+    "lint": "eslint . --max-warnings=0",
+    "storybook": "start-storybook -p 6006",
+    "build-storybook": "build-storybook",
+    "release": "np --no-2fa",
+    "cypress:run": "cypress run --component"
+  },
+  "peerDependencies": {
+    "react": "^16.9.0 || ^17 || ^18",
+    "react-dom": "^16.9.0 || ^17 || ^18"
+  },
+  "devDependencies": {
+    "@babel/core": "^7.8.4",
+    "@babel/eslint-parser": "^7.15.4",
+    "@babel/preset-react": "^7.14.5",
+    "@collapsed-internal/build": "workspace:*",
+    "@collapsed-internal/tsconfig": "workspace:*",
+    "@cypress/vite-dev-server": "^5.0.0",
+    "@storybook/addon-a11y": "^6.5.11",
+    "@storybook/addon-actions": "^6.5.11",
+    "@storybook/addon-essentials": "^6.5.11",
+    "@storybook/addon-links": "^6.5.11",
+    "@storybook/builder-vite": "^0.4.2",
+    "@storybook/react": "^6.5.11",
+    "@testing-library/cypress": "^8.0.7",
+    "@testing-library/jest-dom": "^5.16.0",
+    "@testing-library/react": "^13.4.0",
+    "@types/jest": "^25.1.2",
+    "@types/node": "^16.7.13",
+    "@types/react": "^18",
+    "@types/react-dom": "^18",
+    "@types/styled-components": "^5.0.1",
+    "@types/testing-library__jest-dom": "^5.14.5",
+    "@types/testing-library__react": "^10.2.0",
+    "@vitejs/plugin-react": "^2.2.0",
+    "babel-loader": "^8.2.2",
+    "cypress": "^11.2.0",
+    "eslint-config-collapsed": "workspace:*",
+    "jest": "^29.3.1",
+    "jest-environment-jsdom": "^29.3.1",
+    "np": "^6.4.0",
+    "prettier": "^2.3.2",
+    "react": "^18",
+    "react-docgen-typescript-loader": "^3.7.1",
+    "react-dom": "^18",
+    "styled-components": "^5.2.0",
+    "ts-jest": "^29.0.3",
+    "tslib": "^2.4.1",
+    "tsup": "^6.5.0",
+    "typescript": "^4.9",
+    "vite": "^3.2.4"
+  },
+  "dependencies": {
+    "tiny-warning": "^1.0.3"
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/roginfarrer/collapsed.git",
+    "directory": "packages/react"
+  },
+  "keywords": [
+    "collapse",
+    "react",
+    "collapsible",
+    "animate",
+    "height",
+    "render",
+    "expand",
+    "hooks",
+    "auto"
+  ],
+  "publishConfig": {
+    "access": "public"
+  }
+}
diff --git a/packages/react-collapsed/src/index.ts b/packages/react-collapsed/src/index.ts
new file mode 100644
index 0000000..72b3cd1
--- /dev/null
+++ b/packages/react-collapsed/src/index.ts
@@ -0,0 +1,330 @@
+import {
+  useState,
+  useRef,
+  useEffect,
+  useLayoutEffect as useReactLayoutEffect,
+  CSSProperties,
+} from 'react'
+import {
+  useId,
+  getElementHeight,
+  getAutoHeightDuration,
+  mergeRefs,
+  usePaddingWarning,
+  useControlledState,
+  useEvent,
+  usePrefersReducedMotion,
+  clearAnimationTimeout,
+  Frame,
+  AssignableRef,
+  setAnimationTimeout,
+} from './utils'
+
+const useLayoutEffect =
+  typeof window === 'undefined' ? useEffect : useReactLayoutEffect
+
+export interface UseCollapseInput {
+  /**
+   * If true, the collapsible element is expanded.
+   */
+  isExpanded?: boolean
+  /**
+   * If true, the collapsible element is expanded when it initially mounts.
+   * @default false
+   */
+  defaultExpanded?: boolean
+  /**
+   * Sets the height (Number) to which the elements collapses.
+   * @default 0
+   */
+  collapsedHeight?: number
+  /**
+   * Sets the transition-timing-function of the animation.
+   * @default 'cubic-bezier(0.4, 0, 0.2, 1)'
+   */
+  easing?: string
+  /**
+   * Sets the duration of the animation. If undefined, a 'natural' duration is
+   * calculated based on the distance of the animation.
+   */
+  duration?: number
+  /**
+   * If true, the animation is disabled. Overrides the hooks own logic for
+   * disabling the animation according to reduced motion preferences.
+   */
+  hasDisabledAnimation?: boolean
+  /**
+   * Handler called at each stage of the animation.
+   */
+  onTransitionStateChange?: (
+    state:
+      | 'expandStart'
+      | 'expandEnd'
+      | 'expanding'
+      | 'collapseStart'
+      | 'collapseEnd'
+      | 'collapsing'
+  ) => void
+}
+
+export function useCollapse({
+  duration,
+  easing = 'cubic-bezier(0.4, 0, 0.2, 1)',
+  onTransitionStateChange: propOnTransitionStateChange = () => {},
+  isExpanded: configIsExpanded,
+  defaultExpanded = false,
+  hasDisabledAnimation,
+  ...initialConfig
+}: UseCollapseInput = {}) {
+  const onTransitionStateChange = useEvent(propOnTransitionStateChange)
+  const uniqueId = useId()
+
+  const [isExpanded, setExpanded] = useControlledState(
+    configIsExpanded,
+    defaultExpanded
+  )
+  const prevExpanded = useRef(isExpanded)
+  const [isAnimating, setIsAnimating] = useState(false)
+
+  const prefersReducedMotion = usePrefersReducedMotion()
+  const disableAnimation = hasDisabledAnimation ?? prefersReducedMotion
+
+  // Animation frames
+  const frameId = useRef<number>()
+  const endFrameId = useRef<Frame>()
+
+  // Collapse + toggle elements
+  const collapseElRef = useRef<HTMLElement | null>(null)
+  const [toggleEl, setToggleEl] = useState<HTMLElement | null>(null)
+
+  usePaddingWarning(collapseElRef)
+  const collapsedHeight = `${initialConfig.collapsedHeight || 0}px`
+
+  function setStyles<T extends Partial<CSSStyleDeclaration>>(newStyles: T) {
+    if (!collapseElRef.current) return
+    const target = collapseElRef.current
+
+    for (const property in newStyles) {
+      const value = newStyles[property]
+      if (value) {
+        target.style[property] = value
+      } else {
+        target.style.removeProperty(property)
+      }
+    }
+  }
+
+  useLayoutEffect(() => {
+    const collapse = collapseElRef.current
+    if (!collapse) return
+
+    if (isExpanded === prevExpanded.current) return
+    prevExpanded.current = isExpanded
+
+    function getDuration(height: number | string) {
+      if (disableAnimation) {
+        return 0
+      }
+      return duration ?? getAutoHeightDuration(height)
+    }
+
+    const getTransitionStyles = (height: number | string) =>
+      `height ${getDuration(height)}ms ${easing}`
+
+    const setTransitionEndTimeout = (duration: number) => {
+      function endTransition() {
+        if (isExpanded) {
+          setStyles({
+            height: '',
+            overflow: '',
+            transition: '',
+            display: '',
+          })
+          onTransitionStateChange('expandEnd')
+        } else {
+          setStyles({ transition: '' })
+          onTransitionStateChange('collapseEnd')
+        }
+        setIsAnimating(false)
+      }
+
+      if (endFrameId.current) {
+        clearAnimationTimeout(endFrameId.current)
+      }
+      endFrameId.current = setAnimationTimeout(endTransition, duration)
+    }
+
+    setIsAnimating(true)
+
+    if (isExpanded) {
+      frameId.current = requestAnimationFrame(() => {
+        onTransitionStateChange('expandStart')
+        setStyles({
+          display: 'block',
+          overflow: 'hidden',
+          height: collapsedHeight,
+        })
+        frameId.current = requestAnimationFrame(() => {
+          onTransitionStateChange('expanding')
+          const height = getElementHeight(collapseElRef)
+          setTransitionEndTimeout(getDuration(height))
+
+          if (collapseElRef.current) {
+            // Order is important! Setting directly.
+            collapseElRef.current.style.transition = getTransitionStyles(height)
+            collapseElRef.current.style.height = `${height}px`
+          }
+        })
+      })
+    } else {
+      frameId.current = requestAnimationFrame(() => {
+        onTransitionStateChange('collapseStart')
+        const height = getElementHeight(collapseElRef)
+        setTransitionEndTimeout(getDuration(height))
+        setStyles({
+          transition: getTransitionStyles(height),
+          height: `${height}px`,
+        })
+        frameId.current = requestAnimationFrame(() => {
+          onTransitionStateChange('collapsing')
+          setStyles({
+            height: collapsedHeight,
+            overflow: 'hidden',
+          })
+        })
+      })
+    }
+
+    return () => {
+      if (frameId.current) cancelAnimationFrame(frameId.current)
+      if (endFrameId.current) clearAnimationTimeout(endFrameId.current)
+    }
+  }, [
+    isExpanded,
+    collapsedHeight,
+    disableAnimation,
+    duration,
+    easing,
+    onTransitionStateChange,
+  ])
+
+  return {
+    isExpanded,
+    setExpanded,
+
+    getToggleProps<
+      Args extends {
+        onClick?: React.MouseEventHandler
+        disabled?: boolean
+        [k: string]: unknown
+      },
+      RefKey extends string | undefined = 'ref'
+    >(
+      rest?: Args & {
+        /**
+         * Sets the key of the prop that the component uses for ref assignment
+         * @default 'ref'
+         */
+        refKey?: RefKey
+      }
+    ): { [K in RefKey extends string ? RefKey : 'ref']: AssignableRef<any> } & {
+      onClick: React.MouseEventHandler
+      id: string
+      'aria-controls': string
+      'aria-expanded'?: boolean
+      type?: 'button'
+      disabled?: boolean
+      'aria-disabled'?: boolean
+      role?: 'button'
+      tabIndex?: number
+    } {
+      const { disabled, onClick, refKey } = {
+        refKey: 'ref',
+        onClick() {},
+        disabled: false,
+        ...rest,
+      }
+
+      const isButton = toggleEl ? toggleEl.tagName === 'BUTTON' : undefined
+
+      const theirRef: any = rest?.[refKey || 'ref']
+
+      const props: any = {
+        id: `react-collapsed-toggle-${uniqueId}`,
+        'aria-controls': `react-collapsed-panel-${uniqueId}`,
+        'aria-expanded': isExpanded,
+        onClick(evt: any) {
+          if (disabled) return
+          onClick?.(evt)
+          setExpanded((n) => !n)
+        },
+        [refKey || 'ref']: mergeRefs(theirRef, setToggleEl),
+      }
+
+      const buttonProps = {
+        type: 'button',
+        disabled: disabled ? true : undefined,
+      }
+      const fakeButtonProps = {
+        'aria-disabled': disabled ? true : undefined,
+        role: 'button',
+        tabIndex: disabled ? -1 : 0,
+      }
+
+      if (isButton === false) {
+        return { ...props, ...fakeButtonProps }
+      } else if (isButton === true) {
+        return { ...props, ...buttonProps }
+      } else {
+        return {
+          ...props,
+          ...buttonProps,
+          ...fakeButtonProps,
+        }
+      }
+    },
+
+    getCollapseProps<
+      Args extends { style?: CSSProperties; [k: string]: unknown },
+      RefKey extends string | undefined = 'ref'
+    >(
+      rest?: Args & {
+        /**
+         * Sets the key of the prop that the component uses for ref assignment
+         * @default 'ref'
+         */
+        refKey?: RefKey
+      }
+    ): {
+      [K in RefKey extends string ? RefKey : 'ref']: AssignableRef<any>
+    } & {
+      id: string
+      'aria-hidden': boolean
+      role: string
+      style: CSSProperties
+    } {
+      const { style, refKey } = { refKey: 'ref', style: {}, ...rest }
+      const theirRef: any = rest?.[refKey || 'ref']
+      return {
+        id: `react-collapsed-panel-${uniqueId}`,
+        'aria-hidden': !isExpanded,
+        role: 'region',
+        ...rest,
+        [refKey || 'ref']: mergeRefs(collapseElRef, theirRef),
+        style: {
+          boxSizing: 'border-box',
+          ...(!isAnimating && !isExpanded
+            ? {
+                // collapsed and not animating
+                display: collapsedHeight === '0px' ? 'none' : 'block',
+                height: collapsedHeight,
+                overflow: 'hidden',
+              }
+            : {}),
+          // additional styles passed, e.g. getCollapseProps({style: {}})
+          ...style,
+        },
+      } as any
+    },
+  }
+}
diff --git a/packages/react-collapsed/src/utils/CollapseError.ts b/packages/react-collapsed/src/utils/CollapseError.ts
new file mode 100644
index 0000000..723c685
--- /dev/null
+++ b/packages/react-collapsed/src/utils/CollapseError.ts
@@ -0,0 +1,13 @@
+import warning from 'tiny-warning'
+
+export class CollapseError extends Error {
+  constructor(message: string) {
+    super(`react-collapsed: ${message}`)
+  }
+}
+
+const collapseWarning = (...args: Parameters<typeof warning>) => {
+  return warning(args[0], `[react-collapsed] -- ${args[1]}`)
+}
+
+export { collapseWarning as warning }
diff --git a/packages/react-collapsed/src/utils/index.ts b/packages/react-collapsed/src/utils/index.ts
new file mode 100644
index 0000000..b74f44c
--- /dev/null
+++ b/packages/react-collapsed/src/utils/index.ts
@@ -0,0 +1,121 @@
+import { MutableRefObject, RefObject, useEffect } from 'react'
+import { CollapseError, warning } from './CollapseError'
+
+export { useEvent } from './useEvent'
+export { useControlledState } from './useControlledState'
+export { usePrefersReducedMotion } from './usePrefersReducedMotion'
+export { useId } from './useId'
+export * from './setAnimationTimeout'
+
+/**
+ * React.Ref uses the readonly type `React.RefObject` instead of
+ * `React.MutableRefObject`, We pretty much always assume ref objects are
+ * mutable (at least when we create them), so this type is a workaround so some
+ * of the weird mechanics of using refs with TS.
+ */
+export type AssignableRef<ValueType> =
+  | {
+      bivarianceHack(instance: ValueType | null): void
+    }['bivarianceHack']
+  | MutableRefObject<ValueType | null>
+
+// eslint-disable-next-line @typescript-eslint/no-empty-function
+export const noop = (): void => {}
+
+export function getElementHeight(el: RefObject<HTMLElement>): number {
+  if (!el?.current) {
+    warning(
+      true,
+      `Was not able to find a ref to the collapse element via \`getCollapseProps\`. Ensure that the element exposes its \`ref\` prop. If it exposes the ref prop under a different name (like \`innerRef\`), use the \`refKey\` property to change it. Example:
+
+const collapseProps = getCollapseProps({refKey: 'innerRef'})`
+    )
+    return 0
+  }
+  // scrollHeight will give us the height of the element, even if it's not visible.
+  // clientHeight, offsetHeight, nor getBoundingClientRect().height will do so
+  return el.current.scrollHeight
+}
+
+// https://github.com/mui-org/material-ui/blob/da362266f7c137bf671d7e8c44c84ad5cfc0e9e2/packages/material-ui/src/styles/transitions.js#L89-L98
+export function getAutoHeightDuration(height: number | string): number {
+  if (!height || typeof height === 'string') {
+    return 0
+  }
+
+  const constant = height / 36
+
+  // https://www.wolframalpha.com/input/?i=(4+%2B+15+*+(x+%2F+36+)+**+0.25+%2B+(x+%2F+36)+%2F+5)+*+10
+  return Math.round((4 + 15 * constant ** 0.25 + constant / 5) * 10)
+}
+
+export function assignRef<RefValueType = any>(
+  ref: AssignableRef<RefValueType> | null | undefined,
+  value: any
+) {
+  if (ref == null) return
+  if (typeof ref === 'function') {
+    ref(value)
+  } else {
+    try {
+      ref.current = value
+    } catch (error) {
+      throw new CollapseError(`Cannot assign value "${value}" to ref "${ref}"`)
+    }
+  }
+}
+
+/**
+ * Passes or assigns a value to multiple refs (typically a DOM node). Useful for
+ * dealing with components that need an explicit ref for DOM calculations but
+ * also forwards refs assigned by an app.
+ *
+ * @param refs Refs to fork
+ */
+export function mergeRefs<RefValueType = any>(
+  ...refs: (AssignableRef<RefValueType> | null | undefined)[]
+) {
+  if (refs.every((ref) => ref == null)) {
+    return null
+  }
+  return (node: any) => {
+    refs.forEach((ref) => {
+      assignRef(ref, node)
+    })
+  }
+}
+
+export function usePaddingWarning(element: RefObject<HTMLElement>): void {
+  // @ts-expect-error we do use it in dev
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
+  let warn = (el?: RefObject<HTMLElement>): void => {}
+
+  if (process.env.NODE_ENV !== 'production') {
+    warn = (el) => {
+      if (!el?.current) {
+        return
+      }
+      const { paddingTop, paddingBottom } = window.getComputedStyle(el.current)
+      const hasPadding =
+        (paddingTop && paddingTop !== '0px') ||
+        (paddingBottom && paddingBottom !== '0px')
+
+      warning(
+        !hasPadding,
+        `Padding applied to the collapse element will cause the animation to break and not perform as expected. To fix, apply equivalent padding to the direct descendent of the collapse element. Example:
+
+Before:   <div {...getCollapseProps({style: {padding: 10}})}>{children}</div>
+
+After:   <div {...getCollapseProps()}>
+             <div style={{padding: 10}}>
+                 {children}
+             </div>
+          </div>`
+      )
+    }
+  }
+
+  useEffect(() => {
+    warn(element)
+  }, [element])
+}
diff --git a/packages/react-collapsed/src/utils/setAnimationTimeout.ts b/packages/react-collapsed/src/utils/setAnimationTimeout.ts
new file mode 100644
index 0000000..80b9d80
--- /dev/null
+++ b/packages/react-collapsed/src/utils/setAnimationTimeout.ts
@@ -0,0 +1,25 @@
+export type Frame = {
+  id?: number
+}
+
+export function setAnimationTimeout(callback: () => void, timeout: number) {
+  const startTime = performance.now()
+  const frame: Frame = {}
+
+  function call() {
+    frame.id = requestAnimationFrame((now) => {
+      if (now - startTime > timeout) {
+        callback()
+      } else {
+        call()
+      }
+    })
+  }
+
+  call()
+  return frame
+}
+
+export function clearAnimationTimeout(frame: Frame) {
+  if (frame.id) cancelAnimationFrame(frame.id)
+}
diff --git a/packages/react-collapsed/src/utils/useControlledState.ts b/packages/react-collapsed/src/utils/useControlledState.ts
new file mode 100644
index 0000000..2b120b3
--- /dev/null
+++ b/packages/react-collapsed/src/utils/useControlledState.ts
@@ -0,0 +1,42 @@
+import { useState, useRef, useCallback, useEffect } from 'react'
+import { warning } from './CollapseError'
+import { useEvent } from './useEvent'
+
+export function useControlledState<T>(
+  value: T | undefined,
+  defaultValue: T | undefined,
+  callback?: (value: T) => void
+): [T, (update: T | ((value: T) => T)) => void] {
+  const [state, setState] = useState<T>(defaultValue as T)
+  const initiallyControlled = useRef(typeof value !== 'undefined')
+  const effectiveValue = initiallyControlled.current ? value : state
+  const cb = useEvent(callback)
+
+  const onChange = useCallback(
+    (update: React.SetStateAction<T>) => {
+      const setter = update as (value?: T) => T
+      const newValue =
+        typeof update === 'function' ? setter(effectiveValue) : update
+
+      if (!initiallyControlled.current) {
+        setState(newValue)
+      }
+
+      cb?.(newValue)
+    },
+    [cb, effectiveValue]
+  )
+
+  useEffect(() => {
+    warning(
+      !(initiallyControlled.current && value == null),
+      '`isExpanded` state is changing from controlled to uncontrolled. useCollapse should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled collapse for the lifetime of the component. Check the `isExpanded` prop.'
+    )
+    warning(
+      !(!initiallyControlled.current && value != null),
+      '`isExpanded` state is changing from uncontrolled to controlled. useCollapse should not switch from uncontrolled to controlled (or vice versa). Decide between using a controlled or uncontrolled collapse for the lifetime of the component. Check the `isExpanded` prop.'
+    )
+  }, [value])
+
+  return [effectiveValue as T, onChange]
+}
diff --git a/packages/react-collapsed/src/utils/useEvent.ts b/packages/react-collapsed/src/utils/useEvent.ts
new file mode 100644
index 0000000..0699e66
--- /dev/null
+++ b/packages/react-collapsed/src/utils/useEvent.ts
@@ -0,0 +1,12 @@
+import { useRef, useEffect, useCallback } from 'react'
+
+export function useEvent<T extends (...args: any[]) => any>(callback?: T) {
+  const ref = useRef<T | undefined>(callback)
+
+  useEffect(() => {
+    ref.current = callback
+  })
+
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  return useCallback(((...args: any) => ref.current?.(...args)) as T, [])
+}
diff --git a/packages/react-collapsed/src/utils/useId.ts b/packages/react-collapsed/src/utils/useId.ts
new file mode 100644
index 0000000..d48c114
--- /dev/null
+++ b/packages/react-collapsed/src/utils/useId.ts
@@ -0,0 +1,76 @@
+import * as React from 'react'
+
+const __useId: () => string | undefined =
+  (React as any)['useId'.toString()] || (() => undefined)
+
+export function useReactId() {
+  const id = __useId()
+  return id ?? ''
+}
+
+/**
+ * Taken from Reach
+ * https://github.com/reach/reach-ui/blob/d2b88c50caf52f473a7d20a4493e39e3c5e95b7b/packages/auto-id
+ *
+ * Autogenerate IDs to facilitate WAI-ARIA and server rendering.
+ *
+ * Note: The returned ID will initially be `null` and will update after a
+ * component mounts. Users may need to supply their own ID if they need
+ * consistent values for SSR.
+ *
+ * @see Docs https://reach.tech/auto-id
+ */
+const useIsomorphicLayoutEffect =
+  typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect
+let serverHandoffComplete = false
+let id = 0
+const genId = () => ++id
+export function useUniqueId(idFromProps?: string | null) {
+  /*
+   * If this instance isn't part of the initial render, we don't have to do the
+   * double render/patch-up dance. We can just generate the ID and return it.
+   */
+  const initialId = idFromProps || (serverHandoffComplete ? genId() : null)
+
+  const [id, setId] = React.useState(initialId)
+
+  useIsomorphicLayoutEffect(() => {
+    if (id === null) {
+      /*
+       * Patch the ID after render. We do this in `useLayoutEffect` to avoid any
+       * rendering flicker, though it'll make the first render slower (unlikely
+       * to matter, but you're welcome to measure your app and let us know if
+       * it's a problem).
+       */
+      setId(genId())
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [])
+
+  React.useEffect(() => {
+    if (serverHandoffComplete === false) {
+      /*
+       * Flag all future uses of `useId` to skip the update dance. This is in
+       * `useEffect` because it goes after `useLayoutEffect`, ensuring we don't
+       * accidentally bail out of the patch-up dance prematurely.
+       */
+      serverHandoffComplete = true
+    }
+  }, [])
+  return id != null ? String(id) : undefined
+}
+
+export function useId(idOverride?: string): string | undefined {
+  const reactId = useReactId()
+  const uniqueId = useUniqueId(idOverride)
+
+  if (typeof idOverride === 'string') {
+    return idOverride
+  }
+
+  if (typeof reactId === 'string') {
+    return reactId
+  }
+
+  return uniqueId
+}
diff --git a/packages/react-collapsed/src/utils/usePrefersReducedMotion.ts b/packages/react-collapsed/src/utils/usePrefersReducedMotion.ts
new file mode 100644
index 0000000..80fd652
--- /dev/null
+++ b/packages/react-collapsed/src/utils/usePrefersReducedMotion.ts
@@ -0,0 +1,23 @@
+import { useState, useEffect } from 'react'
+
+const QUERY = '(prefers-reduced-motion: reduce)'
+
+export function usePrefersReducedMotion() {
+  const [prefersReducedMotion, setPrefersReducedMotion] = useState(false)
+
+  useEffect(() => {
+    const mediaQueryList = window.matchMedia(QUERY)
+    // Set the true initial value, now that we're on the client:
+    setPrefersReducedMotion(mediaQueryList.matches)
+
+    const listener = (event: MediaQueryListEvent) => {
+      setPrefersReducedMotion(!event.matches)
+    }
+
+    mediaQueryList.addEventListener('change', listener)
+    return () => {
+      mediaQueryList.removeEventListener('change', listener)
+    }
+  }, [])
+  return prefersReducedMotion
+}
diff --git a/packages/react-collapsed/stories/accordion.stories.tsx b/packages/react-collapsed/stories/accordion.stories.tsx
new file mode 100644
index 0000000..d1bbb6f
--- /dev/null
+++ b/packages/react-collapsed/stories/accordion.stories.tsx
@@ -0,0 +1,106 @@
+import { useState, Children, cloneElement, ReactNode } from 'react'
+import styled from 'styled-components'
+import { excerpt } from './components'
+import { useCollapse } from '..'
+
+const Item = styled.li({
+  all: 'unset',
+  borderBottom: '2px solid #ccc',
+  ':last-child': {
+    borderBottom: 0,
+  },
+})
+
+const Toggle = styled.button({
+  all: 'unset',
+  cursor: 'pointer',
+  padding: 16,
+  fontFamily: 'Helvetica',
+  fontSize: 16,
+  display: 'flex',
+  alignItems: 'center',
+  width: '100%',
+})
+
+const Panel = styled.div({
+  padding: 16,
+  fontFamily: 'Helvetica',
+})
+
+const StyledAccordion = styled.ul({
+  all: 'unset',
+  display: 'flex',
+  flexDirection: 'column',
+  background: 'white',
+  padding: 12,
+})
+
+const Collapse = ({
+  isActive,
+  onSelect,
+  title,
+  children,
+}: {
+  isActive?: boolean
+  onSelect?: () => void
+  title: ReactNode
+  children: ReactNode
+}) => {
+  const { getCollapseProps, getToggleProps } = useCollapse({
+    isExpanded: isActive,
+  })
+
+  return (
+    <Item>
+      <Toggle
+        {...getToggleProps({
+          onClick: onSelect,
+        })}
+      >
+        <span aria-hidden="true" style={{ marginRight: '8px' }}>
+          {isActive ? 'v' : '>'}
+        </span>
+        {title}
+      </Toggle>
+      <div {...getCollapseProps()}>
+        <Panel>{children}</Panel>
+      </div>
+    </Item>
+  )
+}
+
+const AccordionParent = ({ children }: any) => {
+  const [activeIndex, setActiveIndex] = useState<null | number>(null)
+  return (
+    <StyledAccordion>
+      {Children.map(children, (child, index) =>
+        cloneElement(child, {
+          ...child,
+          isActive: activeIndex === index,
+          onSelect: () => setActiveIndex(activeIndex === index ? null : index),
+        })
+      )}
+    </StyledAccordion>
+  )
+}
+
+const Background = styled.div({
+  padding: 20,
+  background: '#efefef',
+})
+
+export const Accordion = () => {
+  return (
+    <Background>
+      <AccordionParent>
+        <Collapse title="Collapse One">{excerpt}</Collapse>
+        <Collapse title="Collapse Two">{excerpt}</Collapse>
+        <Collapse title="Collapse Three">{excerpt}</Collapse>
+      </AccordionParent>
+    </Background>
+  )
+}
+
+export default {
+  title: 'Accordion',
+}
diff --git a/packages/react-collapsed/stories/basic.stories.tsx b/packages/react-collapsed/stories/basic.stories.tsx
new file mode 100644
index 0000000..88828e2
--- /dev/null
+++ b/packages/react-collapsed/stories/basic.stories.tsx
@@ -0,0 +1,34 @@
+import * as React from 'react'
+import { useCollapse } from '..'
+import { Toggle, Collapse, excerpt } from './components'
+
+export const Uncontrolled = () => {
+  const { getToggleProps, getCollapseProps, isExpanded } = useCollapse()
+
+  return (
+    <div>
+      <Toggle {...getToggleProps()}>{isExpanded ? 'Close' : 'Open'}</Toggle>
+      <Collapse {...getCollapseProps()}>{excerpt}</Collapse>
+    </div>
+  )
+}
+
+export const Controlled = () => {
+  const [isExpanded, setOpen] = React.useState<boolean>(true)
+  const { getCollapseProps } = useCollapse({
+    isExpanded,
+  })
+
+  return (
+    <div>
+      <Toggle onClick={() => setOpen((x) => !x)}>
+        {isExpanded ? 'Close' : 'Open'}
+      </Toggle>
+      <Collapse {...getCollapseProps({})}>{excerpt}</Collapse>
+    </div>
+  )
+}
+
+export default {
+  title: 'Basic Usage',
+}
diff --git a/packages/react-collapsed/stories/components.tsx b/packages/react-collapsed/stories/components.tsx
new file mode 100644
index 0000000..8ae1910
--- /dev/null
+++ b/packages/react-collapsed/stories/components.tsx
@@ -0,0 +1,69 @@
+import * as React from 'react'
+import styled from 'styled-components'
+
+export const Toggle = styled.button`
+  box-sizing: border-box;
+  background: white;
+  display: inline-block;
+  text-align: center;
+  box-shadow: 5px 5px 0 black;
+  border: 1px solid black;
+  color: black;
+  cursor: pointer;
+  padding: 12px 24px;
+  font-family: Helvetica;
+  font-size: 16px;
+  transition-timing-function: ease;
+  transition-duration: 150ms;
+  transition-property: all;
+  min-width: 150px;
+  width: 100%;
+
+  @media (min-width: 640px) {
+    width: auto;
+  }
+
+  &:hover,
+  &:focus {
+    background: rgba(225, 225, 225, 0.8);
+  }
+  &:active {
+    background: black;
+    color: white;
+    box-shadow: none;
+  }
+`
+
+export const Content = styled.div`
+  box-sizing: border-box;
+  border: 2px solid black;
+  color: #212121;
+  font-family: Helvetica;
+  padding: 12px;
+  font-size: 16px;
+  line-height: 1.5;
+`
+
+const CollapseContainer = styled.div`
+  margin-top: 8px;
+`
+
+type CollapseProps = {
+  children: React.ReactNode
+  style?: React.CSSProperties
+  [k: string]: unknown
+}
+
+export const Collapse = React.forwardRef(function Collapse(
+  props: CollapseProps,
+  ref?: React.Ref<HTMLDivElement>
+) {
+  return (
+    <CollapseContainer {...props} ref={ref}>
+      <Content>{props.children}</Content>
+    </CollapseContainer>
+  )
+})
+
+export const excerpt =
+  'In the morning I walked down the Boulevard to the rue Soufflot for coffee and brioche. It was a fine morning. The horse-chestnut trees in the Luxembourg gardens were in bloom. There was the pleasant early-morning feeling of a hot day. I read the papers with the coffee and then smoked a cigarette. The flower-women were coming up from the market and arranging their daily stock. Students went by going up to the law school, or down to the Sorbonne. The Boulevard was busy with trams and people going to work.'
diff --git a/packages/react-collapsed/stories/div.stories.tsx b/packages/react-collapsed/stories/div.stories.tsx
new file mode 100644
index 0000000..9a5f9fb
--- /dev/null
+++ b/packages/react-collapsed/stories/div.stories.tsx
@@ -0,0 +1,21 @@
+import { useCollapse } from '..'
+import { Toggle, Collapse, excerpt } from './components'
+
+export default {
+  title: 'Using divs',
+}
+
+export const Div = () => {
+  const { getToggleProps, getCollapseProps, isExpanded } = useCollapse({
+    defaultExpanded: true,
+  })
+
+  return (
+    <div>
+      <Toggle as="div" {...getToggleProps()}>
+        {isExpanded ? 'Close' : 'Open'}
+      </Toggle>
+      <Collapse {...getCollapseProps()}>{excerpt}</Collapse>
+    </div>
+  )
+}
diff --git a/packages/react-collapsed/stories/edge-cases.stories.tsx b/packages/react-collapsed/stories/edge-cases.stories.tsx
new file mode 100644
index 0000000..4ea9673
--- /dev/null
+++ b/packages/react-collapsed/stories/edge-cases.stories.tsx
@@ -0,0 +1,46 @@
+import { useCollapse } from '..'
+import { Toggle, excerpt } from './components'
+
+export default {
+  title: 'Edge cases',
+}
+
+export const PaddingOnCollapseElement = () => {
+  const { getToggleProps, getCollapseProps, isExpanded } = useCollapse()
+
+  return (
+    <div>
+      <Toggle {...getToggleProps()}>{isExpanded ? 'Close' : 'Open'}</Toggle>
+      <div {...getCollapseProps({ style: { padding: 10 } })}>{excerpt}</div>
+    </div>
+  )
+}
+
+export const PaddingOnElementInsideCollapse = () => {
+  const { getToggleProps, getCollapseProps, isExpanded } = useCollapse()
+
+  return (
+    <div>
+      <Toggle {...getToggleProps()}>{isExpanded ? 'Close' : 'Open'}</Toggle>
+      <div {...getCollapseProps()}>
+        <div style={{ padding: 20 }}>{excerpt}</div>
+      </div>
+    </div>
+  )
+}
+
+export const MarginOnCollapseElement = () => {
+  const { getToggleProps, getCollapseProps, isExpanded } = useCollapse()
+
+  return (
+    <div>
+      <Toggle {...getToggleProps()}>{isExpanded ? 'Close' : 'Open'}</Toggle>
+      <div {...getCollapseProps({ style: { backgroundColor: 'red' } })}>
+        <div style={{ marginRight: '-15px', marginBottom: '-15px' }}>
+          <div style={{ marginRight: '15px', marginBottom: '15px' }}>1</div>
+          <div style={{ marginRight: '15px', marginBottom: '15px' }}>2</div>
+        </div>
+      </div>
+    </div>
+  )
+}
diff --git a/packages/react-collapsed/stories/nested.stories.tsx b/packages/react-collapsed/stories/nested.stories.tsx
new file mode 100644
index 0000000..feb4281
--- /dev/null
+++ b/packages/react-collapsed/stories/nested.stories.tsx
@@ -0,0 +1,79 @@
+import { useCollapse } from '..'
+import { Toggle, Collapse } from './components'
+
+export default {
+  title: 'Nested Collapses',
+}
+
+function InnerCollapse() {
+  const { getCollapseProps, getToggleProps, isExpanded } = useCollapse()
+
+  return (
+    <>
+      <p style={{ margin: 0 }}>
+        Friends, Romans, countrymen, lend me your ears;
+        <br />
+        I come to bury Caesar, not to praise him.
+        <br />
+        The evil that men do lives after them;
+        <br />
+        The good is oft interred with their bones;
+        <br />
+        So let it be with Caesar. The noble Brutus
+        <br />
+        Hath told you Caesar was ambitious:
+        <br />
+        If it were so, it was a grievous fault,
+        <br />
+        And grievously hath Caesar answerโ€™d it.
+        <br />
+        Here, under leave of Brutus and the restโ€“
+        <br />
+        For Brutus is an honourable man;
+        <br />
+        So are they all, all honourable menโ€“
+        <br />
+        Come I to speak in Caesarโ€™s funeral.
+      </p>
+      <p {...getCollapseProps({ style: { margin: 0 } })}>
+        He was my friend, faithful and just to me:
+        <br />
+        But Brutus says he was ambitious;
+        <br />
+        And Brutus is an honourable man.
+        <br />
+        He hath brought many captives home to Rome
+        <br />
+        Whose ransoms did the general coffers fill:
+        <br />
+        Did this in Caesar seem ambitious?
+        <br />
+        When that the poor have cried, Caesar hath wept:
+        <br />
+        Ambition should be made of sterner stuff:
+      </p>
+      <Toggle
+        {...getToggleProps({ style: { display: 'block', marginTop: 8 } })}
+      >
+        {isExpanded ? 'Click to collapse' : 'Read more?'}
+      </Toggle>
+    </>
+  )
+}
+
+export function Nested() {
+  const { getCollapseProps, getToggleProps, isExpanded } = useCollapse({
+    defaultExpanded: true,
+  })
+
+  return (
+    <>
+      <Toggle {...getToggleProps()}>{isExpanded ? 'Close' : 'Expand'}</Toggle>
+      <section {...getCollapseProps()}>
+        <Collapse style={{ display: 'inline-block' }}>
+          <InnerCollapse />
+        </Collapse>
+      </section>
+    </>
+  )
+}
diff --git a/packages/react-collapsed/stories/unmount.stories.tsx b/packages/react-collapsed/stories/unmount.stories.tsx
new file mode 100644
index 0000000..129ad6b
--- /dev/null
+++ b/packages/react-collapsed/stories/unmount.stories.tsx
@@ -0,0 +1,39 @@
+import * as React from 'react'
+import { useCollapse } from '..'
+import { Collapse, excerpt, Toggle } from './components'
+
+export default {
+  title: 'Unmount content on collapse',
+  component: useCollapse,
+}
+
+export function Unmount() {
+  const [mountChildren, setMountChildren] = React.useState(true)
+  const { getCollapseProps, getToggleProps, isExpanded } = useCollapse({
+    defaultExpanded: true,
+    onTransitionStateChange(state) {
+      switch (state) {
+        case 'expandStart':
+          setMountChildren(true)
+          break
+        case 'collapseEnd':
+          setMountChildren(false)
+          break
+        default:
+      }
+    },
+  })
+
+  return (
+    <>
+      <Toggle {...getToggleProps()}>{isExpanded ? 'Close' : 'Open'}</Toggle>
+      <div {...getCollapseProps()}>
+        {mountChildren && <Collapse>{excerpt}</Collapse>}
+      </div>
+    </>
+  )
+}
+
+Unmount.story = {
+  name: 'Unmount content when closed',
+}
diff --git a/packages/react-collapsed/tests/index.test.tsx b/packages/react-collapsed/tests/index.test.tsx
new file mode 100644
index 0000000..a41ad87
--- /dev/null
+++ b/packages/react-collapsed/tests/index.test.tsx
@@ -0,0 +1,146 @@
+import * as React from 'react'
+import { render, fireEvent, screen } from '@testing-library/react'
+import { useCollapse } from '../src'
+
+// https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
+Object.defineProperty(window, 'matchMedia', {
+  writable: true,
+  value: jest.fn().mockImplementation((query) => ({
+    matches: false,
+    media: query,
+    onchange: null,
+    addListener: jest.fn(), // deprecated
+    removeListener: jest.fn(), // deprecated
+    addEventListener: jest.fn(),
+    removeEventListener: jest.fn(),
+    dispatchEvent: jest.fn(),
+  })),
+})
+
+const Collapse = ({
+  toggleElement: Toggle = 'div',
+  collapseProps = {},
+  toggleProps = {},
+  ...props
+}: Parameters<typeof useCollapse>[0] & {
+  toggleElement?: React.ElementType
+  collapseProps?: any
+  toggleProps?: any
+}) => {
+  const { getCollapseProps, getToggleProps } = useCollapse(props)
+  return (
+    <>
+      <Toggle {...getToggleProps(toggleProps)} data-testid="toggle">
+        Toggle
+      </Toggle>
+      <div {...getCollapseProps(collapseProps)} data-testid="collapse">
+        <div style={{ height: 400 }}>content</div>
+      </div>
+    </>
+  )
+}
+
+test('does not throw', () => {
+  const result = () => render(<Collapse />)
+  expect(result).not.toThrow()
+})
+
+test('Toggle has expected props when closed (default)', () => {
+  const { rerender } = render(<Collapse toggleElement="div" />)
+  const toggle = screen.getByRole('button')
+  const collapse = screen.getByTestId('collapse')
+  expect(toggle).toHaveAttribute('role', 'button')
+  expect(toggle).toHaveAttribute('tabIndex', '0')
+  expect(toggle).toHaveAttribute('aria-expanded', 'false')
+  expect(toggle).toHaveAttribute('aria-controls', collapse.id)
+
+  rerender(<Collapse toggleElement="button" />)
+  const toggle2 = screen.getByRole('button')
+  const collapse2 = screen.getByTestId('collapse')
+  expect(toggle2).toHaveAttribute('type', 'button')
+  expect(toggle2).toHaveAttribute('aria-expanded', 'false')
+  expect(toggle2).toHaveAttribute('aria-controls', collapse2.id)
+})
+
+test('Toggle has expected props when collapse is open', () => {
+  const { rerender } = render(<Collapse toggleElement="div" isExpanded />)
+  const toggle = screen.getByRole('button')
+  const collapse = screen.getByTestId('collapse')
+  expect(toggle).toHaveAttribute('role', 'button')
+  expect(toggle).toHaveAttribute('tabIndex', '0')
+  expect(toggle).toHaveAttribute('aria-expanded', 'true')
+  expect(toggle).toHaveAttribute('aria-controls', collapse.id)
+
+  rerender(<Collapse toggleElement="button" isExpanded />)
+  const toggle2 = screen.getByRole('button')
+  const collapse2 = screen.getByTestId('collapse')
+  expect(toggle2).toHaveAttribute('type', 'button')
+  expect(toggle2).toHaveAttribute('aria-expanded', 'true')
+  expect(toggle2).toHaveAttribute('aria-controls', collapse2.id)
+})
+
+test('Toggle has expected props when disabled', () => {
+  const { rerender } = render(
+    <Collapse toggleElement="div" toggleProps={{ disabled: true }} />
+  )
+  const toggle = screen.getByRole('button')
+  const collapse = screen.getByTestId('collapse')
+  expect(toggle).toHaveAttribute('role', 'button')
+  expect(toggle).toHaveAttribute('tabIndex', '-1')
+  expect(toggle).toHaveAttribute('aria-expanded', 'false')
+  expect(toggle).toHaveAttribute('aria-controls', collapse.id)
+  expect(toggle).toHaveAttribute('aria-disabled')
+
+  rerender(<Collapse toggleElement="button" toggleProps={{ disabled: true }} />)
+  const toggle2 = screen.getByRole('button')
+  const collapse2 = screen.getByTestId('collapse')
+  expect(toggle2).toHaveAttribute('type', 'button')
+  expect(toggle2).toHaveAttribute('aria-expanded', 'false')
+  expect(toggle2).toHaveAttribute('aria-controls', collapse2.id)
+  expect(toggle2).toHaveAttribute('disabled')
+})
+
+test('Collapse has expected props when closed (default)', () => {
+  render(<Collapse />)
+  const collapse = screen.getByTestId('collapse')
+  expect(collapse).toHaveAttribute('id')
+  expect(collapse.getAttribute('aria-hidden')).toBe('true')
+  expect(collapse).toHaveStyle({ display: 'none', height: '0px' })
+})
+
+test('Collapse has expected props when open', () => {
+  render(<Collapse isExpanded />)
+  const collapse = screen.getByTestId('collapse')
+  expect(collapse).toHaveAttribute('id')
+  expect(collapse).toHaveAttribute('aria-hidden', 'false')
+  expect(collapse.style).not.toContain(
+    expect.objectContaining({
+      display: 'none',
+      height: '0px',
+    })
+  )
+})
+
+test('Re-render does not modify id', () => {
+  const { rerender } = render(<Collapse />)
+  const collapse = screen.getByTestId('collapse')
+  const collapseId = collapse.getAttribute('id')
+
+  rerender(<Collapse defaultExpanded />)
+  expect(collapseId).toEqual(collapse.getAttribute('id'))
+})
+
+test('toggle click calls onClick argument with isExpanded', () => {
+  const onClick = jest.fn()
+  render(<Collapse defaultExpanded toggleProps={{ onClick }} />)
+  const toggle = screen.getByRole('button')
+
+  fireEvent.click(toggle)
+  expect(onClick).toHaveBeenCalled()
+})
+
+test('permits access to the collapse ref', () => {
+  const cb = jest.fn()
+  const { queryByTestId } = render(<Collapse collapseProps={{ ref: cb }} />)
+  expect(cb).toHaveBeenCalledWith(queryByTestId('collapse'))
+})
diff --git a/packages/react-collapsed/tests/utils.test.tsx b/packages/react-collapsed/tests/utils.test.tsx
new file mode 100644
index 0000000..f17b150
--- /dev/null
+++ b/packages/react-collapsed/tests/utils.test.tsx
@@ -0,0 +1,87 @@
+import React from 'react'
+import { render, act } from '@testing-library/react'
+import { useControlledState } from '../src/utils'
+
+describe('useControlledState', () => {
+  let hookReturn: [boolean, React.Dispatch<React.SetStateAction<boolean>>]
+
+  function UseControlledState({
+    defaultExpanded = false,
+    isExpanded,
+  }: {
+    defaultExpanded?: boolean
+    isExpanded?: boolean
+  }) {
+    const result = useControlledState(isExpanded, defaultExpanded)
+
+    hookReturn = result
+
+    return null
+  }
+
+  it('returns a boolean and a function', () => {
+    render(<UseControlledState />)
+
+    expect(hookReturn[0]).toBe(false)
+    expect(typeof hookReturn[1]).toBe('function')
+  })
+
+  it('returns the defaultValue value', () => {
+    render(<UseControlledState defaultExpanded />)
+
+    expect(hookReturn[0]).toBe(true)
+  })
+
+  it('setter toggles the value', () => {
+    render(<UseControlledState defaultExpanded />)
+
+    expect(hookReturn[0]).toBe(true)
+
+    act(() => {
+      hookReturn[1]((n) => !n)
+    })
+
+    expect(hookReturn[0]).toBe(false)
+  })
+
+  describe('dev feedback', () => {
+    // Mocking console.warn so it does not log to the console,
+    // but we can still intercept the message
+    const originalWarn = console.warn
+    let consoleOutput: string[] = []
+    const mockWarn = (output: any) => consoleOutput.push(output)
+
+    beforeEach(() => (console.warn = mockWarn))
+    afterEach(() => {
+      console.warn = originalWarn
+      consoleOutput = []
+    })
+
+    function Foo({ isExpanded }: { isExpanded?: boolean }) {
+      useControlledState(isExpanded, undefined)
+      return <div />
+    }
+
+    it('warns about changing from uncontrolled to controlled', () => {
+      const { rerender } = render(<Foo />)
+      rerender(<Foo isExpanded />)
+
+      expect(consoleOutput[0]).toMatchInlineSnapshot(
+        `"Warning: [react-collapsed] -- \`isExpanded\` state is changing from uncontrolled to controlled. useCollapse should not switch from uncontrolled to controlled (or vice versa). Decide between using a controlled or uncontrolled collapse for the lifetime of the component. Check the \`isExpanded\` prop."`
+      )
+      expect(consoleOutput.length).toBe(1)
+    })
+
+    it('warns about changing from controlled to uncontrolled', () => {
+      // Initially control the value
+      const { rerender } = render(<Foo isExpanded />)
+      // Then re-render without controlling it
+      rerender(<Foo isExpanded={undefined} />)
+
+      expect(consoleOutput[0]).toMatchInlineSnapshot(
+        `"Warning: [react-collapsed] -- \`isExpanded\` state is changing from controlled to uncontrolled. useCollapse should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled collapse for the lifetime of the component. Check the \`isExpanded\` prop."`
+      )
+      expect(consoleOutput.length).toBe(1)
+    })
+  })
+})
diff --git a/packages/react-collapsed/tsconfig.json b/packages/react-collapsed/tsconfig.json
new file mode 100644
index 0000000..25728a8
--- /dev/null
+++ b/packages/react-collapsed/tsconfig.json
@@ -0,0 +1,4 @@
+{
+  "extends": "@collapsed-internal/tsconfig/react.json",
+  "include": ["src", "jest-setup.ts", "tsup.config.ts", "stories", "tests"]
+}
diff --git a/packages/react-collapsed/tsup.config.ts b/packages/react-collapsed/tsup.config.ts
new file mode 100644
index 0000000..c19f603
--- /dev/null
+++ b/packages/react-collapsed/tsup.config.ts
@@ -0,0 +1,11 @@
+import type { defineConfig } from 'tsup'
+import { getTsupConfig, getPackageInfo } from '@collapsed-internal/build'
+
+type TsupConfig = ReturnType<typeof defineConfig>
+
+let { name: packageName, version: packageVersion } = getPackageInfo(__dirname)
+let cfg: TsupConfig = getTsupConfig('src/index.ts', {
+  packageName,
+  packageVersion,
+})
+export default cfg
diff --git a/packages/react-collapsed/vite.config.ts b/packages/react-collapsed/vite.config.ts
new file mode 100644
index 0000000..fdbce28
--- /dev/null
+++ b/packages/react-collapsed/vite.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig, searchForWorkspaceRoot } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+  plugins: [react()],
+  server: {
+    fs: {
+      allow: [searchForWorkspaceRoot(process.cwd())],
+    },
+  },
+})
diff --git a/packages/react/README.md b/packages/react/README.md
index f1b3f02..d52407c 100644
--- a/packages/react/README.md
+++ b/packages/react/README.md
@@ -1,3 +1,9 @@
+# NOTE
+
+You're probably looking for [react-collapsed](../react-collapsed). This package (alongside [@collapsed/core](../core)) is a WIP rewrite to create a Vanilla JS core.
+
+---
+
 # @collapsed/react (useCollapse)
 
 [![CI][ci-badge]][ci]
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ecfdeeb..a5e5570 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,7 @@ importers:
       '@collapsed-internal/tsconfig': workspace:*
       '@types/node': ^16.7.13
       buffer: ^5.5.0
+      eslint: ^7.32.0
       eslint-config-collapsed: workspace:*
       np: ^6.4.0
       prettier: ^2.3.2
@@ -19,6 +20,7 @@ importers:
       '@collapsed-internal/tsconfig': link:internal/tsconfig
       '@types/node': 16.18.3
       buffer: 5.7.1
+      eslint: 7.32.0
       eslint-config-collapsed: link:internal/eslint-config-collapsed
       np: 6.5.0
       prettier: 2.4.1
@@ -200,6 +202,91 @@ importers:
       typescript: 4.9.3
       vite: 3.2.4_@types+node@16.18.3
 
+  packages/react-collapsed:
+    specifiers:
+      '@babel/core': ^7.8.4
+      '@babel/eslint-parser': ^7.15.4
+      '@babel/preset-react': ^7.14.5
+      '@collapsed-internal/build': workspace:*
+      '@collapsed-internal/tsconfig': workspace:*
+      '@cypress/vite-dev-server': ^5.0.0
+      '@storybook/addon-a11y': ^6.5.11
+      '@storybook/addon-actions': ^6.5.11
+      '@storybook/addon-essentials': ^6.5.11
+      '@storybook/addon-links': ^6.5.11
+      '@storybook/builder-vite': ^0.4.2
+      '@storybook/react': ^6.5.11
+      '@testing-library/cypress': ^8.0.7
+      '@testing-library/jest-dom': ^5.16.0
+      '@testing-library/react': ^13.4.0
+      '@types/jest': ^25.1.2
+      '@types/node': ^16.7.13
+      '@types/react': ^18
+      '@types/react-dom': ^18
+      '@types/styled-components': ^5.0.1
+      '@types/testing-library__jest-dom': ^5.14.5
+      '@types/testing-library__react': ^10.2.0
+      '@vitejs/plugin-react': ^2.2.0
+      babel-loader: ^8.2.2
+      cypress: ^11.2.0
+      eslint-config-collapsed: workspace:*
+      jest: ^29.3.1
+      jest-environment-jsdom: ^29.3.1
+      np: ^6.4.0
+      prettier: ^2.3.2
+      react: ^18
+      react-docgen-typescript-loader: ^3.7.1
+      react-dom: ^18
+      styled-components: ^5.2.0
+      tiny-warning: ^1.0.3
+      ts-jest: ^29.0.3
+      tslib: ^2.4.1
+      tsup: ^6.5.0
+      typescript: ^4.9
+      vite: ^3.2.4
+    dependencies:
+      tiny-warning: 1.0.3
+    devDependencies:
+      '@babel/core': 7.20.2
+      '@babel/eslint-parser': 7.15.7_@babel+core@7.20.2
+      '@babel/preset-react': 7.14.5_@babel+core@7.20.2
+      '@collapsed-internal/build': link:../../internal/build
+      '@collapsed-internal/tsconfig': link:../../internal/tsconfig
+      '@cypress/vite-dev-server': 5.0.0
+      '@storybook/addon-a11y': 6.5.13_biqbaboplfbrettd7655fr4n2y
+      '@storybook/addon-actions': 6.5.13_biqbaboplfbrettd7655fr4n2y
+      '@storybook/addon-essentials': 6.5.13_wf3vkn74tbeiauqgwtkjdgsucu
+      '@storybook/addon-links': 6.5.13_biqbaboplfbrettd7655fr4n2y
+      '@storybook/builder-vite': 0.4.2_4q74n645get5mu4v5eciwu5nti
+      '@storybook/react': 6.5.13_wf3vkn74tbeiauqgwtkjdgsucu
+      '@testing-library/cypress': 8.0.7_cypress@11.2.0
+      '@testing-library/jest-dom': 5.16.5
+      '@testing-library/react': 13.4.0_biqbaboplfbrettd7655fr4n2y
+      '@types/jest': 25.2.3
+      '@types/node': 16.18.3
+      '@types/react': 18.0.25
+      '@types/react-dom': 18.0.9
+      '@types/styled-components': 5.1.14
+      '@types/testing-library__jest-dom': 5.14.5
+      '@types/testing-library__react': 10.2.0_biqbaboplfbrettd7655fr4n2y
+      '@vitejs/plugin-react': 2.2.0_vite@3.2.4
+      babel-loader: 8.3.0_@babel+core@7.20.2
+      cypress: 11.2.0
+      eslint-config-collapsed: link:../../internal/eslint-config-collapsed
+      jest: 29.3.1_@types+node@16.18.3
+      jest-environment-jsdom: 29.3.1
+      np: 6.5.0
+      prettier: 2.8.0
+      react: 18.2.0
+      react-docgen-typescript-loader: 3.7.2_typescript@4.9.3
+      react-dom: 18.2.0_react@18.2.0
+      styled-components: 5.3.1_biqbaboplfbrettd7655fr4n2y
+      ts-jest: 29.0.3_6crhf7ajeizammv76u753sn6i4
+      tslib: 2.4.1
+      tsup: 6.5.0_typescript@4.9.3
+      typescript: 4.9.3
+      vite: 3.2.4_@types+node@16.18.3
+
 packages:
 
   /@adobe/css-tools/4.0.1:
@@ -1986,7 +2073,7 @@ packages:
       ajv: 6.12.6
       debug: 4.3.4
       espree: 7.3.1
-      globals: 13.11.0
+      globals: 13.19.0
       ignore: 4.0.6
       import-fresh: 3.3.0
       js-yaml: 3.14.1
@@ -1996,6 +2083,23 @@ packages:
       - supports-color
     dev: false
 
+  /@eslint/eslintrc/1.4.1:
+    resolution: {integrity: sha512-XXrH9Uarn0stsyldqDYq8r++mROmWRI1xKMXa640Bb//SY1+ECYX6VzT6Lcx5frD0V30XieqJ0oX9I2Xj5aoMA==}
+    engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+    dependencies:
+      ajv: 6.12.6
+      debug: 4.3.4
+      espree: 9.5.0
+      globals: 13.19.0
+      ignore: 5.2.4
+      import-fresh: 3.3.0
+      js-yaml: 4.1.0
+      minimatch: 3.1.2
+      strip-json-comments: 3.1.1
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
   /@gar/promisify/1.1.2:
     resolution: {integrity: sha512-82cpyJyKRoQoRi+14ibCeGPu0CwypgtBAdBhq1WfvagpCZNKqwXbKwXllYSMG91DhmG4jt9gN8eP6lGOtozuaw==}
     dev: true
@@ -2004,16 +2108,26 @@ packages:
     resolution: {integrity: sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==}
     engines: {node: '>=10.10.0'}
     dependencies:
-      '@humanwhocodes/object-schema': 1.2.0
+      '@humanwhocodes/object-schema': 1.2.1
       debug: 4.3.4
       minimatch: 3.1.2
     transitivePeerDependencies:
       - supports-color
     dev: false
 
-  /@humanwhocodes/object-schema/1.2.0:
-    resolution: {integrity: sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w==}
-    dev: false
+  /@humanwhocodes/config-array/0.9.5:
+    resolution: {integrity: sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw==}
+    engines: {node: '>=10.10.0'}
+    dependencies:
+      '@humanwhocodes/object-schema': 1.2.1
+      debug: 4.3.4
+      minimatch: 3.1.2
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
+  /@humanwhocodes/object-schema/1.2.1:
+    resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==}
 
   /@istanbuljs/load-nyc-config/1.1.0:
     resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==}
@@ -2288,6 +2402,23 @@ packages:
       chalk: 4.1.2
     dev: true
 
+  /@joshwooding/vite-plugin-react-docgen-typescript/0.2.1_vf3nqk3ewnpqc5dulqzhw4xcru:
+    resolution: {integrity: sha512-ou4ZJSXMMWHqGS4g8uNRbC5TiTWxAgQZiVucoUrOCWuPrTbkpJbmVyIi9jU72SBry7gQtuMEDp4YR8EEXAg7VQ==}
+    peerDependencies:
+      typescript: '>= 4.3.x'
+      vite: ^3.0.0 || ^4.0.0
+    peerDependenciesMeta:
+      typescript:
+        optional: true
+    dependencies:
+      glob: 7.2.0
+      glob-promise: 4.2.2_glob@7.2.0
+      magic-string: 0.27.0
+      react-docgen-typescript: 2.2.2_typescript@4.9.3
+      typescript: 4.9.3
+      vite: 3.2.4_@types+node@16.18.3
+    dev: true
+
   /@jridgewell/gen-mapping/0.1.1:
     resolution: {integrity: sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==}
     engines: {node: '>=6.0.0'}
@@ -3741,6 +3872,57 @@ packages:
       util-deprecate: 1.0.2
     dev: true
 
+  /@storybook/builder-vite/0.4.2_4q74n645get5mu4v5eciwu5nti:
+    resolution: {integrity: sha512-KBBiDdYCK0BCOns8iCVrtzaIiYQF9NjwQ7u3HY/a0bmAuaXPd9m3t6Tvp2jNwmdRAtHOHXnM+d6ROJAI77DCYg==}
+    peerDependencies:
+      '@storybook/mdx2-csf': '>=1.0.0-next.0'
+      '@sveltejs/vite-plugin-svelte': ^2.0.0
+      '@vitejs/plugin-react': ^3.0.0
+      '@vitejs/plugin-react-swc': ^3.0.0
+      '@vitejs/plugin-vue': ^4.0.0
+      vite: '>= 4.0.0'
+      vue-docgen-api: ^4.40.0
+    peerDependenciesMeta:
+      '@storybook/mdx2-csf':
+        optional: true
+      '@sveltejs/vite-plugin-svelte':
+        optional: true
+      '@vitejs/plugin-react':
+        optional: true
+      '@vitejs/plugin-react-swc':
+        optional: true
+      '@vitejs/plugin-vue':
+        optional: true
+      vue-docgen-api:
+        optional: true
+    dependencies:
+      '@joshwooding/vite-plugin-react-docgen-typescript': 0.2.1_vf3nqk3ewnpqc5dulqzhw4xcru
+      '@storybook/core-common': 6.5.13_lb6du3saekb5anf2gjv3wxj3oq
+      '@storybook/mdx1-csf': 1.0.0-next.0_react@18.2.0
+      '@storybook/node-logger': 6.5.13
+      '@storybook/semver': 7.3.2
+      '@storybook/source-loader': 6.5.13_biqbaboplfbrettd7655fr4n2y
+      '@vitejs/plugin-react': 2.2.0_vite@3.2.4
+      ast-types: 0.14.2
+      es-module-lexer: 0.9.3
+      glob: 7.2.0
+      glob-promise: 4.2.2_glob@7.2.0
+      magic-string: 0.26.7
+      react-docgen: 6.0.0-alpha.3
+      slash: 3.0.0
+      sveltedoc-parser: 4.2.1
+      vite: 3.2.4_@types+node@16.18.3
+    transitivePeerDependencies:
+      - eslint
+      - react
+      - react-dom
+      - supports-color
+      - typescript
+      - vue-template-compiler
+      - webpack-cli
+      - webpack-command
+    dev: true
+
   /@storybook/builder-webpack4/6.5.13_lb6du3saekb5anf2gjv3wxj3oq:
     resolution: {integrity: sha512-Agqy3IKPv3Nl8QqdS7PjtqLp+c0BD8+/3A2ki/YfKqVz+F+J34EpbZlh3uU053avm1EoNQHSmhZok3ZlWH6O7A==}
     peerDependencies:
@@ -4284,6 +4466,16 @@ packages:
       - supports-color
     dev: true
 
+  /@storybook/mdx1-csf/1.0.0-next.0_react@18.2.0:
+    resolution: {integrity: sha512-zrv5yRKSOQum69DU+5+wK1VcfmHv8xdqLsACeVOHJp1Mq2eG8s3/WuEA0L3wljoebbuKasZecn2Eu/TQCt+0og==}
+    dependencies:
+      '@mdx-js/mdx': 1.6.22
+      '@mdx-js/react': 1.6.22_react@18.2.0
+    transitivePeerDependencies:
+      - react
+      - supports-color
+    dev: true
+
   /@storybook/node-logger/6.5.13:
     resolution: {integrity: sha512-/r5aVZAqZRoy5FyNk/G4pj7yKJd3lJfPbAaOHVROv2IF7PJP/vtRaDkcfh0g2U6zwuDxGIqSn80j+qoEli9m5A==}
     dependencies:
@@ -5418,6 +5610,14 @@ packages:
     dependencies:
       acorn: 7.4.1
 
+  /acorn-jsx/5.3.2_acorn@8.8.1:
+    resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
+    peerDependencies:
+      acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
+    dependencies:
+      acorn: 8.8.1
+    dev: true
+
   /acorn-walk/7.2.0:
     resolution: {integrity: sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==}
     engines: {node: '>=0.4.0'}
@@ -5674,6 +5874,10 @@ packages:
     dependencies:
       sprintf-js: 1.0.3
 
+  /argparse/2.0.1:
+    resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+    dev: true
+
   /aria-query/4.2.2:
     resolution: {integrity: sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==}
     engines: {node: '>=6.0'}
@@ -7808,6 +8012,7 @@ packages:
 
   /domelementtype/2.2.0:
     resolution: {integrity: sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==}
+    dev: false
 
   /domelementtype/2.3.0:
     resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
@@ -7819,6 +8024,13 @@ packages:
       webidl-conversions: 7.0.0
     dev: true
 
+  /domhandler/3.3.0:
+    resolution: {integrity: sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA==}
+    engines: {node: '>= 4'}
+    dependencies:
+      domelementtype: 2.3.0
+    dev: true
+
   /domhandler/4.2.2:
     resolution: {integrity: sha512-PzE9aBMsdZO8TK4BnuJwH0QT41wgMbRzuZrHUcpYncEjmQazq8QEaBWgLG7ZyC/DAZKEgglpIA6j4Qn/HmxS3w==}
     engines: {node: '>= 4'}
@@ -8330,7 +8542,6 @@ packages:
   /escape-string-regexp/4.0.0:
     resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
     engines: {node: '>=10'}
-    dev: false
 
   /escodegen/2.0.0:
     resolution: {integrity: sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==}
@@ -8592,6 +8803,14 @@ packages:
       esrecurse: 4.3.0
       estraverse: 4.3.0
 
+  /eslint-scope/7.1.1:
+    resolution: {integrity: sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==}
+    engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+    dependencies:
+      esrecurse: 4.3.0
+      estraverse: 5.2.0
+    dev: true
+
   /eslint-utils/2.1.0:
     resolution: {integrity: sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==}
     engines: {node: '>=6'}
@@ -8609,6 +8828,16 @@ packages:
       eslint-visitor-keys: 2.1.0
     dev: false
 
+  /eslint-utils/3.0.0_eslint@8.4.1:
+    resolution: {integrity: sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==}
+    engines: {node: ^10.0.0 || ^12.0.0 || >= 14.0.0}
+    peerDependencies:
+      eslint: '>=5'
+    dependencies:
+      eslint: 8.4.1
+      eslint-visitor-keys: 2.1.0
+    dev: true
+
   /eslint-visitor-keys/1.3.0:
     resolution: {integrity: sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==}
     engines: {node: '>=4'}
@@ -8618,6 +8847,11 @@ packages:
     resolution: {integrity: sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==}
     engines: {node: '>=10'}
 
+  /eslint-visitor-keys/3.3.0:
+    resolution: {integrity: sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==}
+    engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+    dev: true
+
   /eslint/7.32.0:
     resolution: {integrity: sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==}
     engines: {node: ^10.12.0 || >=12.0.0}
@@ -8643,7 +8877,7 @@ packages:
       file-entry-cache: 6.0.1
       functional-red-black-tree: 1.0.1
       glob-parent: 5.1.2
-      globals: 13.11.0
+      globals: 13.19.0
       ignore: 4.0.6
       import-fresh: 3.3.0
       imurmurhash: 0.1.4
@@ -8667,6 +8901,53 @@ packages:
       - supports-color
     dev: false
 
+  /eslint/8.4.1:
+    resolution: {integrity: sha512-TxU/p7LB1KxQ6+7aztTnO7K0i+h0tDi81YRY9VzB6Id71kNz+fFYnf5HD5UOQmxkzcoa0TlVZf9dpMtUv0GpWg==}
+    engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+    hasBin: true
+    dependencies:
+      '@eslint/eslintrc': 1.4.1
+      '@humanwhocodes/config-array': 0.9.5
+      ajv: 6.12.6
+      chalk: 4.1.2
+      cross-spawn: 7.0.3
+      debug: 4.3.4
+      doctrine: 3.0.0
+      enquirer: 2.3.6
+      escape-string-regexp: 4.0.0
+      eslint-scope: 7.1.1
+      eslint-utils: 3.0.0_eslint@8.4.1
+      eslint-visitor-keys: 3.3.0
+      espree: 9.2.0
+      esquery: 1.4.0
+      esutils: 2.0.3
+      fast-deep-equal: 3.1.3
+      file-entry-cache: 6.0.1
+      functional-red-black-tree: 1.0.1
+      glob-parent: 6.0.2
+      globals: 13.19.0
+      ignore: 4.0.6
+      import-fresh: 3.3.0
+      imurmurhash: 0.1.4
+      is-glob: 4.0.3
+      js-yaml: 4.1.0
+      json-stable-stringify-without-jsonify: 1.0.1
+      levn: 0.4.1
+      lodash.merge: 4.6.2
+      minimatch: 3.1.2
+      natural-compare: 1.4.0
+      optionator: 0.9.1
+      progress: 2.0.3
+      regexpp: 3.2.0
+      semver: 7.3.5
+      strip-ansi: 6.0.1
+      strip-json-comments: 3.1.1
+      text-table: 0.2.0
+      v8-compile-cache: 2.3.0
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
   /espree/7.3.1:
     resolution: {integrity: sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==}
     engines: {node: ^10.12.0 || >=12.0.0}
@@ -8676,6 +8957,24 @@ packages:
       eslint-visitor-keys: 1.3.0
     dev: false
 
+  /espree/9.2.0:
+    resolution: {integrity: sha512-oP3utRkynpZWF/F2x/HZJ+AGtnIclaR7z1pYPxy7NYM2fSO6LgK/Rkny8anRSPK/VwEA1eqm2squui0T7ZMOBg==}
+    engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+    dependencies:
+      acorn: 8.8.1
+      acorn-jsx: 5.3.2_acorn@8.8.1
+      eslint-visitor-keys: 3.3.0
+    dev: true
+
+  /espree/9.5.0:
+    resolution: {integrity: sha512-JPbJGhKc47++oo4JkEoTe2wjy4fmMwvFpgJT9cQzmfXKp22Dr6Hf1tdCteLz1h0P3t+mGvWZ+4Uankvh8+c6zw==}
+    engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+    dependencies:
+      acorn: 8.8.1
+      acorn-jsx: 5.3.2_acorn@8.8.1
+      eslint-visitor-keys: 3.3.0
+    dev: true
+
   /esprima/4.0.1:
     resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
     engines: {node: '>=4'}
@@ -8686,7 +8985,6 @@ packages:
     engines: {node: '>=0.10'}
     dependencies:
       estraverse: 5.2.0
-    dev: false
 
   /esrecurse/4.3.0:
     resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
@@ -9027,7 +9325,6 @@ packages:
     engines: {node: ^10.12.0 || >=12.0.0}
     dependencies:
       flat-cache: 3.0.4
-    dev: false
 
   /file-loader/6.2.0_webpack@4.46.0:
     resolution: {integrity: sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==}
@@ -9398,7 +9695,6 @@ packages:
 
   /functional-red-black-tree/1.0.1:
     resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==}
-    dev: false
 
   /functions-have-names/1.2.2:
     resolution: {integrity: sha512-bLgc3asbWdwPbx2mNk2S49kmJCuQeu0nfmaOgbs8WIyzzkw3r4htszdIi9Q9EMezDPTYuJx2wvjZ/EwgAthpnA==}
@@ -9511,6 +9807,13 @@ packages:
     dependencies:
       is-glob: 4.0.3
 
+  /glob-parent/6.0.2:
+    resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
+    engines: {node: '>=10.13.0'}
+    dependencies:
+      is-glob: 4.0.3
+    dev: true
+
   /glob-promise/3.4.0_glob@7.2.0:
     resolution: {integrity: sha512-q08RJ6O+eJn+dVanerAndJwIcumgbDdYiUT7zFQl3Wm1xD6fBKtah7H8ZJChj4wP+8C+QfeVy8xautR7rdmKEw==}
     engines: {node: '>=4'}
@@ -9521,6 +9824,16 @@ packages:
       glob: 7.2.0
     dev: true
 
+  /glob-promise/4.2.2_glob@7.2.0:
+    resolution: {integrity: sha512-xcUzJ8NWN5bktoTIX7eOclO1Npxd/dyVqUJxlLIDasT4C7KZyqlPIwkdJ0Ypiy3p2ZKahTjK4M9uC3sNSfNMzw==}
+    engines: {node: '>=12'}
+    peerDependencies:
+      glob: ^7.1.6
+    dependencies:
+      '@types/glob': 7.1.4
+      glob: 7.2.0
+    dev: true
+
   /glob-to-regexp/0.3.0:
     resolution: {integrity: sha512-Iozmtbqv0noj0uDDqoL0zNq0VBEfK2YFoMAZoxJe4cwphvLR+JskfF30QhXHOR4m3KrE6NLRYw+U9MRXvifyig==}
     dev: true
@@ -9575,19 +9888,11 @@ packages:
     engines: {node: '>=4'}
     dev: true
 
-  /globals/13.11.0:
-    resolution: {integrity: sha512-08/xrJ7wQjK9kkkRoI3OFUBbLx4f+6x3SGwcPvQ0QH6goFDrOU2oyAWrmh3dJezu65buo+HBMzAMQy6rovVC3g==}
-    engines: {node: '>=8'}
-    dependencies:
-      type-fest: 0.20.2
-    dev: false
-
   /globals/13.19.0:
     resolution: {integrity: sha512-dkQ957uSRWHw7CFXLUtUHQI3g3aWApYhfNR2O6jn/907riyTYKVBmxYVROkBcY614FSSeSJh7Xm7SrUWCxvJMQ==}
     engines: {node: '>=8'}
     dependencies:
       type-fest: 0.20.2
-    dev: true
 
   /globalthis/1.0.2:
     resolution: {integrity: sha512-ZQnSFO1la8P7auIOQECnm0sSuoMeaSq0EEdXMBFF2QJO4uNcwbyhSgG3MruWNbFTqCLmxVwGOl7LZ9kASvHdeQ==}
@@ -9603,7 +9908,7 @@ packages:
       array-union: 2.1.0
       dir-glob: 3.0.1
       fast-glob: 3.2.7
-      ignore: 5.1.8
+      ignore: 5.2.4
       merge2: 1.4.1
       slash: 3.0.0
 
@@ -9984,10 +10289,19 @@ packages:
       timsort: 0.3.0
     dev: true
 
+  /htmlparser2-svelte/4.1.0:
+    resolution: {integrity: sha512-+4f4RBFz7Rj2Hp0ZbFbXC+Kzbd6S9PgjiuFtdT76VMNgKogrEZy0pG2UrPycPbrZzVEIM5lAT3lAdkSTCHLPjg==}
+    dependencies:
+      domelementtype: 2.3.0
+      domhandler: 3.3.0
+      domutils: 2.8.0
+      entities: 2.2.0
+    dev: true
+
   /htmlparser2/6.1.0:
     resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==}
     dependencies:
-      domelementtype: 2.2.0
+      domelementtype: 2.3.0
       domhandler: 4.3.1
       domutils: 2.8.0
       entities: 2.2.0
@@ -10117,6 +10431,11 @@ packages:
   /ignore/5.1.8:
     resolution: {integrity: sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==}
     engines: {node: '>= 4'}
+    dev: false
+
+  /ignore/5.2.4:
+    resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==}
+    engines: {node: '>= 4'}
 
   /import-fresh/3.3.0:
     resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
@@ -11370,6 +11689,13 @@ packages:
       argparse: 1.0.10
       esprima: 4.0.1
 
+  /js-yaml/4.1.0:
+    resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
+    hasBin: true
+    dependencies:
+      argparse: 2.0.1
+    dev: true
+
   /jsbn/0.1.1:
     resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==}
     dev: true
@@ -11451,7 +11777,6 @@ packages:
 
   /json-stable-stringify-without-jsonify/1.0.1:
     resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
-    dev: false
 
   /json-stringify-safe/5.0.1:
     resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==}
@@ -11623,7 +11948,6 @@ packages:
     dependencies:
       prelude-ls: 1.2.1
       type-check: 0.4.0
-    dev: false
 
   /lightningcss-darwin-arm64/1.18.0:
     resolution: {integrity: sha512-OqjydwtiNPgdH1ByIjA1YzqvDG/OMR6L3LPN6wRl1729LB0y4Mik7L06kmZaTb+pvUHr+NmDd2KCwnlrQ4zO3w==}
@@ -11932,7 +12256,6 @@ packages:
 
   /lodash.merge/4.6.2:
     resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
-    dev: false
 
   /lodash.once/4.1.1:
     resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
@@ -12073,6 +12396,13 @@ packages:
       sourcemap-codec: 1.4.8
     dev: true
 
+  /magic-string/0.27.0:
+    resolution: {integrity: sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==}
+    engines: {node: '>=12'}
+    dependencies:
+      '@jridgewell/sourcemap-codec': 1.4.14
+    dev: true
+
   /make-dir/2.1.0:
     resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==}
     engines: {node: '>=6'}
@@ -12975,7 +13305,6 @@ packages:
       prelude-ls: 1.2.1
       type-check: 0.4.0
       word-wrap: 1.2.3
-    dev: false
 
   /ordered-binary/1.4.0:
     resolution: {integrity: sha512-EHQ/jk4/a9hLupIKxTfUsQRej1Yd/0QLQs3vGvIqg5ZtCYSzNhkzHoZc7Zf4e4kUlDaC3Uw8Q/1opOLNN2OKRQ==}
@@ -13620,7 +13949,6 @@ packages:
   /prelude-ls/1.2.1:
     resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
     engines: {node: '>= 0.8.0'}
-    dev: false
 
   /prepend-http/2.0.0:
     resolution: {integrity: sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA==}
@@ -13648,7 +13976,6 @@ packages:
     resolution: {integrity: sha512-9Lmg8hTFZKG0Asr/kW9Bp8tJjRVluO8EJQVfY2T7FMw9T5jy4I/Uvx0Rca/XWf50QQ1/SS48+6IJWnrb+2yemA==}
     engines: {node: '>=10.13.0'}
     hasBin: true
-    dev: false
 
   /pretty-bytes/5.6.0:
     resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==}
@@ -13706,7 +14033,6 @@ packages:
   /progress/2.0.3:
     resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
     engines: {node: '>=0.4.0'}
-    dev: false
 
   /promise-inflight/1.0.1:
     resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==}
@@ -13983,6 +14309,25 @@ packages:
       - supports-color
     dev: true
 
+  /react-docgen/6.0.0-alpha.3:
+    resolution: {integrity: sha512-DDLvB5EV9As1/zoUsct6Iz2Cupw9FObEGD3DMcIs3EDFIoSKyz8FZtoWj3Wj+oodrU4/NfidN0BL5yrapIcTSA==}
+    engines: {node: '>=12.0.0'}
+    hasBin: true
+    dependencies:
+      '@babel/core': 7.20.2
+      '@babel/generator': 7.20.4
+      ast-types: 0.14.2
+      commander: 2.20.3
+      doctrine: 3.0.0
+      estree-to-babel: 3.2.1
+      neo-async: 2.6.2
+      node-dir: 0.1.17
+      resolve: 1.22.1
+      strip-indent: 3.0.0
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
   /react-dom/18.2.0_react@18.2.0:
     resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==}
     peerDependencies:
@@ -14219,7 +14564,6 @@ packages:
   /regexpp/3.2.0:
     resolution: {integrity: sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==}
     engines: {node: '>=8'}
-    dev: false
 
   /regexpu-core/4.8.0:
     resolution: {integrity: sha512-1F6bYsoYiz6is+oz70NWur2Vlh9KWtswuRuzJOfeYUrfPX2o8n74AnUVaOGDbUqVGO9fNHu48/pjJO4sNVwsOg==}
@@ -15324,6 +15668,17 @@ packages:
     resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
     engines: {node: '>= 0.4'}
 
+  /sveltedoc-parser/4.2.1:
+    resolution: {integrity: sha512-sWJRa4qOfRdSORSVw9GhfDEwsbsYsegnDzBevUCF6k/Eis/QqCu9lJ6I0+d/E2wOWCjOhlcJ3+jl/Iur+5mmCw==}
+    engines: {node: '>=10.0.0'}
+    dependencies:
+      eslint: 8.4.1
+      espree: 9.2.0
+      htmlparser2-svelte: 4.1.0
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
   /svgo/2.8.0:
     resolution: {integrity: sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==}
     engines: {node: '>=10.13.0'}
@@ -15988,7 +16343,6 @@ packages:
     engines: {node: '>= 0.8.0'}
     dependencies:
       prelude-ls: 1.2.1
-    dev: false
 
   /type-detect/4.0.8:
     resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==}
diff --git a/turbo.json b/turbo.json
index 2292aab..5a0b4bc 100644
--- a/turbo.json
+++ b/turbo.json
@@ -6,6 +6,10 @@
       "outputs": ["dist/**"],
       "inputs": ["internal/*", "packages/*"]
     },
+    "watch": {
+      "outputs": ["dist/**"],
+      "inputs": ["internal/*", "packages/*"]
+    },
     "lint": {
       "outputs": []
     },