diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4578611058..aabc667f5a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,12 @@ Motion adheres to [Semantic Versioning](http://semver.org/).
Undocumented APIs should be considered internal and may change without warning.
+## [12.26.0] 2026-01-11
+
+### Added
+
+- Support for multiple output value maps with `useTransform`.
+
## [12.25.0] 2026-01-09
### Added
diff --git a/dev/html/package.json b/dev/html/package.json
index 1f7209c02e..1e3d61421e 100644
--- a/dev/html/package.json
+++ b/dev/html/package.json
@@ -1,7 +1,7 @@
{
"name": "html-env",
"private": true,
- "version": "12.25.0",
+ "version": "12.26.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -10,8 +10,8 @@
"preview": "vite preview"
},
"dependencies": {
- "framer-motion": "^12.25.0",
- "motion": "^12.25.0",
+ "framer-motion": "^12.26.0",
+ "motion": "^12.26.0",
"motion-dom": "^12.24.11",
"react": "^18.3.1",
"react-dom": "^18.3.1"
diff --git a/dev/next/package.json b/dev/next/package.json
index bd8c0d4bc5..61a823bb1d 100644
--- a/dev/next/package.json
+++ b/dev/next/package.json
@@ -1,7 +1,7 @@
{
"name": "next-env",
"private": true,
- "version": "12.25.0",
+ "version": "12.26.0",
"type": "module",
"scripts": {
"dev": "next dev",
@@ -10,7 +10,7 @@
"build": "next build"
},
"dependencies": {
- "motion": "^12.25.0",
+ "motion": "^12.26.0",
"next": "15.4.10",
"react": "19.0.0",
"react-dom": "19.0.0"
diff --git a/dev/react-19/package.json b/dev/react-19/package.json
index b1efd78306..5903b2ba69 100644
--- a/dev/react-19/package.json
+++ b/dev/react-19/package.json
@@ -1,7 +1,7 @@
{
"name": "react-19-env",
"private": true,
- "version": "12.25.0",
+ "version": "12.26.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -11,7 +11,7 @@
"preview": "vite preview"
},
"dependencies": {
- "motion": "^12.25.0",
+ "motion": "^12.26.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
diff --git a/dev/react/package.json b/dev/react/package.json
index 48f4fa00ec..e96ca1c1c3 100644
--- a/dev/react/package.json
+++ b/dev/react/package.json
@@ -1,7 +1,7 @@
{
"name": "react-env",
"private": true,
- "version": "12.25.0",
+ "version": "12.26.0",
"type": "module",
"scripts": {
"dev": "yarn vite",
@@ -11,7 +11,7 @@
"preview": "yarn vite preview"
},
"dependencies": {
- "framer-motion": "^12.25.0",
+ "framer-motion": "^12.26.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
diff --git a/lerna.json b/lerna.json
index a7fac08c01..266eb26012 100644
--- a/lerna.json
+++ b/lerna.json
@@ -1,5 +1,5 @@
{
- "version": "12.25.0",
+ "version": "12.26.0",
"packages": [
"packages/*",
"dev/*"
diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json
index e31b71ef9b..39e4e80cbe 100644
--- a/packages/framer-motion/package.json
+++ b/packages/framer-motion/package.json
@@ -1,6 +1,6 @@
{
"name": "framer-motion",
- "version": "12.25.0",
+ "version": "12.26.0",
"description": "A simple and powerful JavaScript animation library",
"main": "dist/cjs/index.js",
"module": "dist/es/index.mjs",
diff --git a/packages/framer-motion/src/value/__tests__/use-transform.test.tsx b/packages/framer-motion/src/value/__tests__/use-transform.test.tsx
index 59d90fe93c..0833558f14 100644
--- a/packages/framer-motion/src/value/__tests__/use-transform.test.tsx
+++ b/packages/framer-motion/src/value/__tests__/use-transform.test.tsx
@@ -307,3 +307,152 @@ describe("CSS logical properties", () => {
expect(container.firstChild).toHaveStyle("inset-inline: 30px")
})
})
+
+describe("as output map", () => {
+ test("sets initial values", async () => {
+ const Component = () => {
+ const x = useMotionValue(100)
+ const { opacity, scale } = useTransform(x, [0, 200], {
+ opacity: [0, 1],
+ scale: [0.5, 1],
+ })
+ return
+ }
+
+ const { container } = render()
+ expect(container.firstChild).toHaveStyle("opacity: 0.5")
+ expect(container.firstChild).toHaveStyle(
+ "transform: translateX(100px) scale(0.75)"
+ )
+ })
+
+ test("updates values when input changes", async () => {
+ const x = motionValue(100)
+
+ const Component = () => {
+ const { opacity, scale } = useTransform(x, [0, 200], {
+ opacity: [0, 1],
+ scale: [0.5, 1.5],
+ })
+ return
+ }
+
+ const { container } = render()
+ expect(container.firstChild).toHaveStyle("opacity: 0.5")
+ expect(container.firstChild).toHaveStyle(
+ "transform: translateX(100px)"
+ )
+
+ x.set(200)
+
+ await nextFrame()
+ expect(container.firstChild).toHaveStyle("opacity: 1")
+ expect(container.firstChild).toHaveStyle(
+ "transform: translateX(200px) scale(1.5)"
+ )
+ })
+
+ test("works with color values", async () => {
+ const Component = () => {
+ const progress = useMotionValue(0.5)
+ const { backgroundColor, borderColor } = useTransform(
+ progress,
+ [0, 1],
+ {
+ backgroundColor: ["#ff0000", "#0000ff"],
+ borderColor: ["#000000", "#ffffff"],
+ }
+ )
+ return (
+
+ )
+ }
+
+ const { container } = render()
+ // Colors are interpolated
+ expect(container.firstChild).toHaveStyle(
+ "background-color: rgba(180, 0, 180, 1)"
+ )
+ })
+
+ test("supports transform options", async () => {
+ const Component = () => {
+ const x = useMotionValue(250)
+ const { opacity } = useTransform(
+ x,
+ [0, 200],
+ {
+ opacity: [0, 0.5],
+ },
+ { clamp: false }
+ )
+ return
+ }
+
+ const { container } = render()
+ // Value exceeds 0.5 because clamp is false (250/200 * 0.5 = 0.625)
+ expect(container.firstChild).toHaveStyle("opacity: 0.625")
+ })
+
+ test("maintains keys across renders even if outputMap keys change", async () => {
+ let capturedKeys: string[] = []
+
+ const Component = ({ includeExtra }: { includeExtra: boolean }) => {
+ const x = useMotionValue(100)
+
+ // Note: In practice, users should not change keys, but the hook
+ // should handle this gracefully by using the original keys
+ const outputMap: { [key: string]: number[] } = includeExtra
+ ? { opacity: [0, 1], scale: [0.5, 1], rotation: [0, 360] }
+ : { opacity: [0, 1], scale: [0.5, 1] }
+
+ const result = useTransform(x, [0, 200], outputMap)
+
+ if (capturedKeys.length === 0) {
+ capturedKeys = Object.keys(result)
+ }
+
+ return
+ }
+
+ const { rerender } = render()
+
+ // The keys should be captured on first render
+ expect(capturedKeys).toEqual(["opacity", "scale"])
+
+ // Even if we try to add a new key, it won't be in the result
+ rerender()
+ })
+
+ test("responds to input range changes", async () => {
+ const x = motionValue(100)
+
+ const Component = ({ max }: { max: number }) => {
+ const { opacity } = useTransform(x, [0, max], {
+ opacity: [0, 1],
+ })
+ return
+ }
+
+ const { container, rerender } = render()
+ expect(container.firstChild).toHaveStyle("opacity: 0.5")
+
+ rerender()
+ await nextMicrotask()
+ expect(container.firstChild).toHaveStyle("opacity: 1")
+ })
+
+ test("is correctly typed", async () => {
+ const Component = () => {
+ const x = useMotionValue(0)
+ const { opacity, scale } = useTransform(x, [0, 1], {
+ opacity: [0, 1],
+ scale: [0.5, 1],
+ })
+
+ return
+ }
+
+ render()
+ })
+})
diff --git a/packages/framer-motion/src/value/use-transform.ts b/packages/framer-motion/src/value/use-transform.ts
index aa6ab0f598..8120f36d88 100644
--- a/packages/framer-motion/src/value/use-transform.ts
+++ b/packages/framer-motion/src/value/use-transform.ts
@@ -22,6 +22,10 @@ type Transformer =
*/
| MultiTransformer
+interface OutputMap {
+ [key: string]: O[]
+}
+
/**
* Create a `MotionValue` that transforms the output of another `MotionValue` by mapping it from one range of values into another.
*
@@ -126,7 +130,46 @@ export function useTransform(
transformer: MultiTransformer
): MotionValue
export function useTransform(transformer: () => O): MotionValue
-export function useTransform(
+
+/**
+ * Create multiple `MotionValue`s that transform the output of another `MotionValue` by mapping it from one range of values into multiple output ranges.
+ *
+ * @remarks
+ *
+ * This is useful when you want to derive multiple values from a single input value.
+ * The keys of the output map must remain constant across renders.
+ *
+ * ```jsx
+ * export const MyComponent = () => {
+ * const x = useMotionValue(0)
+ * const { opacity, scale } = useTransform(x, [0, 100], {
+ * opacity: [0, 1],
+ * scale: [0.5, 1]
+ * })
+ *
+ * return (
+ *
+ * )
+ * }
+ * ```
+ *
+ * @param inputValue - `MotionValue`
+ * @param inputRange - A linear series of numbers (either all increasing or decreasing)
+ * @param outputMap - An object where keys map to output ranges. Each output range must be the same length as `inputRange`.
+ * @param options - Transform options applied to all outputs
+ *
+ * @returns An object with the same keys as `outputMap`, where each value is a `MotionValue`
+ *
+ * @public
+ */
+export function useTransform(
+ inputValue: MotionValue,
+ inputRange: InputRange,
+ outputMap: { [key in K]: O[] },
+ options?: TransformOptions
+): { [key in K]: MotionValue }
+
+export function useTransform(
input:
| MotionValue
| MotionValue[]
@@ -134,13 +177,32 @@ export function useTransform(
| MotionValue[]
| (() => O),
inputRangeOrTransformer?: InputRange | Transformer,
- outputRange?: O[],
+ outputRangeOrMap?: O[] | OutputMap,
options?: TransformOptions
-): MotionValue {
+): MotionValue | { [key in K]: MotionValue } {
if (typeof input === "function") {
return useComputed(input)
}
+ /**
+ * Detect if outputRangeOrMap is an output map (object with keys)
+ * rather than an output range (array).
+ */
+ const isOutputMap =
+ outputRangeOrMap !== undefined &&
+ !Array.isArray(outputRangeOrMap) &&
+ typeof inputRangeOrTransformer !== "function"
+
+ if (isOutputMap) {
+ return useMapTransform(
+ input as MotionValue,
+ inputRangeOrTransformer as InputRange,
+ outputRangeOrMap as OutputMap,
+ options
+ ) as { [key in K]: MotionValue }
+ }
+
+ const outputRange = outputRangeOrMap as O[] | undefined
const transformer =
typeof inputRangeOrTransformer === "function"
? inputRangeOrTransformer
@@ -172,3 +234,22 @@ function useListTransform(
return transformer(latest)
})
}
+
+function useMapTransform(
+ inputValue: MotionValue,
+ inputRange: InputRange,
+ outputMap: OutputMap,
+ options?: TransformOptions
+): { [key: string]: MotionValue } {
+ /**
+ * Capture keys once to ensure hooks are called in consistent order.
+ */
+ const keys = useConstant(() => Object.keys(outputMap))
+ const output = useConstant<{ [key: string]: MotionValue }>(() => ({}))
+
+ for (const key of keys) {
+ output[key] = useTransform(inputValue, inputRange, outputMap[key], options)
+ }
+
+ return output
+}
diff --git a/packages/motion/package.json b/packages/motion/package.json
index e9a15280a6..c89251e72a 100644
--- a/packages/motion/package.json
+++ b/packages/motion/package.json
@@ -1,6 +1,6 @@
{
"name": "motion",
- "version": "12.25.0",
+ "version": "12.26.0",
"description": "An animation library for JavaScript and React.",
"main": "dist/cjs/index.js",
"module": "dist/es/index.mjs",
@@ -76,7 +76,7 @@
"postpublish": "git push --tags"
},
"dependencies": {
- "framer-motion": "^12.25.0",
+ "framer-motion": "^12.26.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
diff --git a/yarn.lock b/yarn.lock
index a3f9694be0..6a576f4d25 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7420,7 +7420,7 @@ __metadata:
languageName: node
linkType: hard
-"framer-motion@^12.25.0, framer-motion@workspace:packages/framer-motion":
+"framer-motion@^12.26.0, framer-motion@workspace:packages/framer-motion":
version: 0.0.0-use.local
resolution: "framer-motion@workspace:packages/framer-motion"
dependencies:
@@ -8192,8 +8192,8 @@ __metadata:
version: 0.0.0-use.local
resolution: "html-env@workspace:dev/html"
dependencies:
- framer-motion: ^12.25.0
- motion: ^12.25.0
+ framer-motion: ^12.26.0
+ motion: ^12.26.0
motion-dom: ^12.24.11
react: ^18.3.1
react-dom: ^18.3.1
@@ -11013,11 +11013,11 @@ __metadata:
languageName: unknown
linkType: soft
-"motion@^12.25.0, motion@workspace:packages/motion":
+"motion@^12.26.0, motion@workspace:packages/motion":
version: 0.0.0-use.local
resolution: "motion@workspace:packages/motion"
dependencies:
- framer-motion: ^12.25.0
+ framer-motion: ^12.26.0
tslib: ^2.4.0
peerDependencies:
"@emotion/is-prop-valid": "*"
@@ -11134,7 +11134,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "next-env@workspace:dev/next"
dependencies:
- motion: ^12.25.0
+ motion: ^12.26.0
next: 15.4.10
react: 19.0.0
react-dom: 19.0.0
@@ -12599,7 +12599,7 @@ __metadata:
"@typescript-eslint/parser": ^7.2.0
"@vitejs/plugin-react-swc": ^3.5.0
eslint-plugin-react-refresh: ^0.4.6
- motion: ^12.25.0
+ motion: ^12.26.0
react: ^19.0.0
react-dom: ^19.0.0
vite: ^5.2.0
@@ -12683,7 +12683,7 @@ __metadata:
"@typescript-eslint/parser": ^7.2.0
"@vitejs/plugin-react-swc": ^3.5.0
eslint-plugin-react-refresh: ^0.4.6
- framer-motion: ^12.25.0
+ framer-motion: ^12.26.0
react: ^18.3.1
react-dom: ^18.3.1
vite: ^5.2.0