diff --git a/README.md b/README.md
index cc1728fafd..fb2e307156 100644
--- a/README.md
+++ b/README.md
@@ -88,6 +88,7 @@
- [**Animations**](./docs/Animations.md)
- [`useRaf`](./docs/useRaf.md) — re-renders component on each `requestAnimationFrame`.
- [`useInterval`](./docs/useInterval.md) and [`useHarmonicIntervalFn`](./docs/useHarmonicIntervalFn.md) — re-renders component on a set interval using `setInterval`.
+ - [`useInterpolations`](./docs/useInterpolations.md) — interpolates a map of numeric values over time.
- [`useSpring`](./docs/useSpring.md) — interpolates number over time according to spring dynamics.
- [`useTimeout`](./docs/useTimeout.md) — re-renders component after a timeout.
- [`useTimeoutFn`](./docs/useTimeoutFn.md) — calls given function after a timeout. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/animation-usetimeoutfn--demo)
diff --git a/docs/useInterpolations.md b/docs/useInterpolations.md
new file mode 100644
index 0000000000..943869b0a0
--- /dev/null
+++ b/docs/useInterpolations.md
@@ -0,0 +1,50 @@
+# `useInterpolations`
+
+React animation hook that interpolates a map of numeric values over time.
+
+## Usage
+
+```jsx
+import { useInterpolations } from 'react-use';
+
+const Demo = () => {
+ const values = useInterpolations({
+ left: [0, 100],
+ top: [0, 50],
+ opacity: [0, 1]
+ }, 'inCirc', 1000);
+
+ return (
+
+ );
+};
+```
+
+## Reference
+
+```ts
+useInterpolations>(
+ map: T,
+ easingName?: string,
+ ms?: number,
+ delay?: number
+): { [K in keyof T]: number }
+```
+
+Returns an object with the same keys as `map`, where each value is interpolated between its `[start, end]` range.
+
+- `map` — required, object where each value is a `[start, end]` tuple of numbers to interpolate.
+- `easingName` — one of the valid [easing names](https://github.com/streamich/ts-easing/blob/master/src/index.ts), defaults to `inCirc`.
+- `ms` — milliseconds for how long to keep re-rendering component, defaults to `200`.
+- `delay` — delay in milliseconds after which to start re-rendering component, defaults to `0`.
+
diff --git a/src/index.ts b/src/index.ts
index 62b69356b7..fdf7ea555a 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -35,6 +35,7 @@ export { default as useHoverDirty } from './useHoverDirty';
export { default as useIdle } from './useIdle';
export { default as useIntersection } from './useIntersection';
export { default as useInterval } from './useInterval';
+export { default as useInterpolations } from './useInterpolations';
export { default as useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect';
export { default as useKey } from './useKey';
export { default as createBreakpoint } from './factory/createBreakpoint';
diff --git a/src/useInterpolations.ts b/src/useInterpolations.ts
new file mode 100644
index 0000000000..0bb768daf5
--- /dev/null
+++ b/src/useInterpolations.ts
@@ -0,0 +1,71 @@
+import { useMemo } from 'react';
+import useTween from './useTween';
+
+export type InterpolationMap = Record;
+
+const formatMapEntryValue = (value: unknown): string => {
+ // Best-effort stringify for dev error logs; avoids throwing on circular structures.
+ try {
+ const json = JSON.stringify(value);
+ return json;
+ } catch {
+ // ignore
+ }
+
+ return String(value);
+};
+
+const useInterpolations = (
+ map: T,
+ easingName: string = 'inCirc',
+ ms: number = 200,
+ delay: number = 0
+): { [K in keyof T]: number } => {
+ const t = useTween(easingName, ms, delay);
+
+ return useMemo(() => {
+ if (process.env.NODE_ENV !== 'production') {
+ if (!map || typeof map !== 'object') {
+ console.error('useInterpolations() expected "map" to be an object.');
+ return {} as { [K in keyof T]: number };
+ }
+ }
+
+ const keys = Object.keys(map) as Array;
+
+ if (process.env.NODE_ENV !== 'production') {
+ for (const key of keys) {
+ const value = map[key];
+ const keyString = String(key);
+ if (!Array.isArray(value) || value.length !== 2) {
+ const valueString = formatMapEntryValue(value);
+ console.error(
+ `useInterpolations() expected map["${keyString}"] to be a [start, end] tuple, got ${valueString}.`
+ );
+ return {} as { [K in keyof T]: number };
+ }
+ if (typeof value[0] !== 'number' || typeof value[1] !== 'number') {
+ console.error(
+ `useInterpolations() expected map["${keyString}"] to contain numbers, got [${typeof value[0]}, ${typeof value[1]}].`
+ );
+ return {} as { [K in keyof T]: number };
+ }
+ if (!Number.isFinite(value[0]) || !Number.isFinite(value[1])) {
+ console.error(
+ `useInterpolations() expected map["${keyString}"] to contain finite numbers, got [${value[0]}, ${value[1]}].`
+ );
+ return {} as { [K in keyof T]: number };
+ }
+ }
+ }
+
+ const result = {} as { [K in keyof T]: number };
+ for (const key of keys) {
+ const [start, end] = map[key];
+ result[key] = start + (end - start) * t;
+ }
+ return result;
+ }, [map, t]);
+};
+
+export default useInterpolations;
diff --git a/stories/useInterpolations.story.tsx b/stories/useInterpolations.story.tsx
new file mode 100644
index 0000000000..25ed87dc3a
--- /dev/null
+++ b/stories/useInterpolations.story.tsx
@@ -0,0 +1,37 @@
+import { storiesOf } from '@storybook/react';
+import * as React from 'react';
+import { useInterpolations } from '../src';
+import ShowDocs from './util/ShowDocs';
+
+const Demo = () => {
+ const values = useInterpolations(
+ {
+ left: [0, 300],
+ top: [0, 200],
+ opacity: [0, 1],
+ },
+ 'inOutCirc',
+ 2000
+ );
+
+ return (
+
+
+
{JSON.stringify(values, null, 2)}
+
+ );
+};
+
+storiesOf('Animation/useInterpolations', module)
+ .add('Docs', () => )
+ .add('Demo', () => );
diff --git a/tests/useInterpolations.test.ts b/tests/useInterpolations.test.ts
new file mode 100644
index 0000000000..860c3d58c1
--- /dev/null
+++ b/tests/useInterpolations.test.ts
@@ -0,0 +1,136 @@
+import { renderHook } from '@testing-library/react-hooks';
+import * as useTween from '../src/useTween';
+import useInterpolations from '../src/useInterpolations';
+
+let spyUseTween;
+
+beforeEach(() => {
+ spyUseTween = jest.spyOn(useTween, 'default').mockReturnValue(0.5);
+});
+
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
+it('should interpolate map values with default parameters', () => {
+ const { result } = renderHook(() =>
+ useInterpolations({
+ left: [0, 100],
+ top: [50, 150],
+ opacity: [0, 1],
+ })
+ );
+
+ expect(result.current.left).toBe(50);
+ expect(result.current.top).toBe(100);
+ expect(result.current.opacity).toBe(0.5);
+ expect(spyUseTween).toHaveBeenCalledTimes(1);
+ expect(spyUseTween).toHaveBeenCalledWith('inCirc', 200, 0);
+});
+
+it('should interpolate map values with custom parameters', () => {
+ const { result } = renderHook(() =>
+ useInterpolations(
+ {
+ x: [10, 20],
+ y: [-5, 5],
+ },
+ 'outCirc',
+ 500,
+ 100
+ )
+ );
+
+ expect(result.current.x).toBe(15);
+ expect(result.current.y).toBe(0);
+ expect(spyUseTween).toHaveBeenCalledTimes(1);
+ expect(spyUseTween).toHaveBeenCalledWith('outCirc', 500, 100);
+});
+
+it('should interpolate at t=0', () => {
+ spyUseTween.mockReturnValue(0);
+
+ const { result } = renderHook(() =>
+ useInterpolations({
+ left: [10, 90],
+ top: [20, 80],
+ })
+ );
+
+ expect(result.current.left).toBe(10);
+ expect(result.current.top).toBe(20);
+});
+
+it('should interpolate at t=1', () => {
+ spyUseTween.mockReturnValue(1);
+
+ const { result } = renderHook(() =>
+ useInterpolations({
+ left: [10, 90],
+ top: [20, 80],
+ })
+ );
+
+ expect(result.current.left).toBe(90);
+ expect(result.current.top).toBe(80);
+});
+
+describe('when invalid map is provided', () => {
+ beforeEach(() => {
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ it('should log an error when map is not an object', () => {
+ const { result } = renderHook(() =>
+ useInterpolations(null as unknown as Record)
+ );
+
+ expect(result.current).toEqual({});
+ expect(console.error).toHaveBeenCalledTimes(1);
+ expect(console.error).toHaveBeenCalledWith(
+ 'useInterpolations() expected "map" to be an object.'
+ );
+ });
+
+ it('should log an error when map value is not a tuple', () => {
+ const { result } = renderHook(() =>
+ useInterpolations({
+ left: [10] as unknown as readonly [number, number],
+ })
+ );
+
+ expect(result.current).toEqual({});
+ expect(console.error).toHaveBeenCalledTimes(1);
+ expect(console.error).toHaveBeenCalledWith(
+ expect.stringContaining('useInterpolations() expected map["left"] to be a [start, end] tuple')
+ );
+ });
+
+ it('should log an error when map value contains non-numbers', () => {
+ const { result } = renderHook(() =>
+ useInterpolations({
+ left: ['0', 100] as unknown as readonly [number, number],
+ })
+ );
+
+ expect(result.current).toEqual({});
+ expect(console.error).toHaveBeenCalledTimes(1);
+ expect(console.error).toHaveBeenCalledWith(
+ expect.stringContaining('useInterpolations() expected map["left"] to contain numbers')
+ );
+ });
+
+ it('should log an error when map value contains non-finite numbers', () => {
+ const { result } = renderHook(() =>
+ useInterpolations({
+ left: [0, Infinity],
+ })
+ );
+
+ expect(result.current).toEqual({});
+ expect(console.error).toHaveBeenCalledTimes(1);
+ expect(console.error).toHaveBeenCalledWith(
+ expect.stringContaining('useInterpolations() expected map["left"] to contain finite numbers')
+ );
+ });
+});