diff --git a/.gitignore b/.gitignore
index 3c3629e..48d3b1f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
node_modules
+public/scripts
diff --git a/AnimatedListDemo.tsx b/AnimatedListDemo.tsx
new file mode 100644
index 0000000..42b106e
--- /dev/null
+++ b/AnimatedListDemo.tsx
@@ -0,0 +1,5 @@
+import React from 'react';
+
+export const AnimatedListDemo = () => {
+ return Hello, world;
+};
diff --git a/index.tsx b/index.tsx
new file mode 100644
index 0000000..02549e0
--- /dev/null
+++ b/index.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import { Root, createRoot } from 'react-dom/client';
+import { AnimatedListDemo } from './AnimatedListDemo';
+
+class AnimatedListComponent extends HTMLElement {
+ root: Root;
+
+ constructor() {
+ super();
+ }
+
+ connectedCallback() {
+ console.log('Hello, world!');
+ const rootNode = document.createElement('main');
+ this.appendChild(rootNode);
+ this.root = createRoot(rootNode);
+ this.root.render();
+ }
+}
+
+customElements.define('react-animated-list-demo', AnimatedListComponent);
diff --git a/package-lock.json b/package-lock.json
index 1bfb253..0ab72e1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,8 @@
"react-dom": "18.3.1"
},
"devDependencies": {
+ "@types/react": "18.3.3",
+ "@types/react-dom": "18.3.0",
"esbuild": "0.23.0"
}
},
@@ -424,6 +426,41 @@
"node": ">=18"
}
},
+ "node_modules/@types/prop-types": {
+ "version": "15.7.12",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz",
+ "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.3",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz",
+ "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz",
+ "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/react": "*"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/esbuild": {
"version": "0.23.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.0.tgz",
diff --git a/package.json b/package.json
index 3587545..fdf8e32 100644
--- a/package.json
+++ b/package.json
@@ -4,6 +4,8 @@
"description": "A demo of an implementation of an animated list in React Typescript.",
"main": "index.tsx",
"scripts": {
+ "build": "esbuild index.tsx --bundle",
+ "dev": "esbuild index.tsx --bundle --watch --serve --outdir=./public/scripts --servedir=./public",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
@@ -13,6 +15,8 @@
"author": "Matthew Cardarelli",
"license": "MIT",
"devDependencies": {
+ "@types/react": "18.3.3",
+ "@types/react-dom": "18.3.0",
"esbuild": "0.23.0"
},
"dependencies": {
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..6b67d9b
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+ React animated list demo
+
+
+
+
+
+
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..4316054
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "jsx": "preserve",
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["DOM", "ESNext"],
+ "noEmit": true,
+ "alwaysStrict": true
+ }
+}
diff --git a/useAnimatedListItems.ts b/useAnimatedListItems.ts
new file mode 100644
index 0000000..f8191a9
--- /dev/null
+++ b/useAnimatedListItems.ts
@@ -0,0 +1,113 @@
+import { useLayoutEffect, useRef, useState } from 'react';
+
+interface ItemTops {
+ [id: number]: number | undefined;
+}
+
+interface ListItemRefsById {
+ [id: number]: HTMLLIElement | undefined;
+}
+
+interface ItemOffsets {
+ [id: number]: number | undefined;
+}
+
+interface ItemStyles {
+ position: 'relative';
+ top: number;
+ transition: string;
+}
+
+interface UseAnimatedListItemArgs {
+ /**
+ * **IMPORTANT - This list must be memoized. The array reference should only change when the
+ * list of items changes.**
+ *
+ * The list of item keys. Each key should uniquely identify a `` component, and
+ * should be the same value passed to the `ListItem`'s `key` prop.
+ */
+ keys: number[] | undefined;
+}
+
+interface UseAnimatedListItemReturns {
+ /**
+ * Item styles that should be passed to the `sx` prop of each `ListItem`. The styles are
+ * mapped per item key.
+ */
+ itemStyles: { [key: number]: ItemStyles | undefined };
+
+ /**
+ * Function that must be called within the `ref` callback prop of each `ListItem`.
+ *
+ * @param {number} key - the `ListItem`'s unique key.
+ * @param {HTMLLIElement} li - the list item element reference.
+ * @returns {void}
+ */
+ updateItemRef: (key: number, li: HTMLLIElement | null) => void;
+
+ /**
+ * Function that must be called within each and every callback that causes the list of items
+ * to refresh or update. This is used to take a "snapshot" of the items current positions prior
+ * to any change.
+ *
+ * @returns {void}
+ */
+ updateItemPositions: () => void;
+}
+
+/**
+ * This hook can be plugged into a MUI to animate the components whenever
+ * change their order.
+ */
+export const useAnimatedListItems = ({
+ keys,
+}: UseAnimatedListItemArgs): UseAnimatedListItemReturns => {
+ const itemRefs = useRef({});
+ const itemTops = useRef({});
+ const [itemOffsets, setItemOffsets] = useState({});
+
+ useLayoutEffect(() => {
+ if (!keys) return;
+
+ const newItemOffsets: ItemOffsets = {};
+ keys.forEach((key) => {
+ const itemRef = itemRefs.current[key];
+ if (itemRef) {
+ const currentTop = itemRef.getBoundingClientRect().top;
+ const prevTop = itemTops.current[key] || 0;
+ const offset = -(currentTop - prevTop);
+ newItemOffsets[key] = offset;
+ }
+ });
+ setItemOffsets(newItemOffsets);
+
+ requestAnimationFrame(() => {
+ setItemOffsets({});
+ });
+ }, [keys]);
+
+ let itemStyles: UseAnimatedListItemReturns['itemStyles'] = {};
+ keys?.forEach((key) => {
+ itemStyles[key] = {
+ position: 'relative',
+ top: itemOffsets[key] || 0,
+ transition:
+ !itemTops.current[key] || itemOffsets[key] ? 'top 0s' : 'top 1s',
+ };
+ });
+
+ return {
+ itemStyles,
+ updateItemRef: (key, li) => {
+ li === null ? delete itemRefs.current[key] : (itemRefs.current[key] = li);
+ },
+ updateItemPositions: () => {
+ keys?.forEach((key) => {
+ const itemRef = itemRefs.current[key];
+ if (itemRef) {
+ itemTops.current[key] = itemRef.getBoundingClientRect().top;
+ }
+ });
+ },
+ };
+};