Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions dev/html/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "html-env",
"private": true,
"version": "12.25.0",
"version": "12.26.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand All @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions dev/next/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "next-env",
"private": true,
"version": "12.25.0",
"version": "12.26.0",
"type": "module",
"scripts": {
"dev": "next dev",
Expand All @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions dev/react-19/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "react-19-env",
"private": true,
"version": "12.25.0",
"version": "12.26.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand All @@ -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"
},
Expand Down
4 changes: 2 additions & 2 deletions dev/react/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "react-env",
"private": true,
"version": "12.25.0",
"version": "12.26.0",
"type": "module",
"scripts": {
"dev": "yarn vite",
Expand All @@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion lerna.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "12.25.0",
"version": "12.26.0",
"packages": [
"packages/*",
"dev/*"
Expand Down
2 changes: 1 addition & 1 deletion packages/framer-motion/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
149 changes: 149 additions & 0 deletions packages/framer-motion/src/value/__tests__/use-transform.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <motion.div style={{ x, opacity, scale }} />
}

const { container } = render(<Component />)
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 <motion.div style={{ x, opacity, scale }} />
}

const { container } = render(<Component />)
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 (
<motion.div style={{ backgroundColor, borderColor }} />
)
}

const { container } = render(<Component />)
// 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 <motion.div style={{ opacity }} />
}

const { container } = render(<Component />)
// 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 <motion.div style={{ opacity: result.opacity }} />
}

const { rerender } = render(<Component includeExtra={false} />)

// 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(<Component includeExtra={true} />)
})

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 <motion.div style={{ opacity }} />
}

const { container, rerender } = render(<Component max={200} />)
expect(container.firstChild).toHaveStyle("opacity: 0.5")

rerender(<Component max={100} />)
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 <motion.div style={{ x, opacity, scale }} />
}

render(<Component />)
})
})
87 changes: 84 additions & 3 deletions packages/framer-motion/src/value/use-transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ type Transformer<I, O> =
*/
| MultiTransformer<AnyResolvedKeyframe, O>

interface OutputMap<O> {
[key: string]: O[]
}

/**
* Create a `MotionValue` that transforms the output of another `MotionValue` by mapping it from one range of values into another.
*
Expand Down Expand Up @@ -126,21 +130,79 @@ export function useTransform<I, O>(
transformer: MultiTransformer<I, O>
): MotionValue<O>
export function useTransform<I, O>(transformer: () => O): MotionValue<O>
export function useTransform<I, O>(

/**
* 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 (
* <motion.div style={{ opacity, scale, x }} />
* )
* }
* ```
*
* @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<K extends string, O>(
inputValue: MotionValue<number>,
inputRange: InputRange,
outputMap: { [key in K]: O[] },
options?: TransformOptions<O>
): { [key in K]: MotionValue<O> }

export function useTransform<I, O, K extends string>(
input:
| MotionValue<I>
| MotionValue<string>[]
| MotionValue<number>[]
| MotionValue<AnyResolvedKeyframe>[]
| (() => O),
inputRangeOrTransformer?: InputRange | Transformer<I, O>,
outputRange?: O[],
outputRangeOrMap?: O[] | OutputMap<O>,
options?: TransformOptions<O>
): MotionValue<O> {
): MotionValue<O> | { [key in K]: MotionValue<O> } {
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<number>,
inputRangeOrTransformer as InputRange,
outputRangeOrMap as OutputMap<O>,
options
) as { [key in K]: MotionValue<O> }
}

const outputRange = outputRangeOrMap as O[] | undefined
const transformer =
typeof inputRangeOrTransformer === "function"
? inputRangeOrTransformer
Expand Down Expand Up @@ -172,3 +234,22 @@ function useListTransform<I, O>(
return transformer(latest)
})
}

function useMapTransform<O>(
inputValue: MotionValue<number>,
inputRange: InputRange,
outputMap: OutputMap<O>,
options?: TransformOptions<O>
): { [key: string]: MotionValue<O> } {
/**
* Capture keys once to ensure hooks are called in consistent order.
*/
const keys = useConstant(() => Object.keys(outputMap))
const output = useConstant<{ [key: string]: MotionValue<O> }>(() => ({}))

for (const key of keys) {
output[key] = useTransform(inputValue, inputRange, outputMap[key], options)
}

return output
}
4 changes: 2 additions & 2 deletions packages/motion/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -76,7 +76,7 @@
"postpublish": "git push --tags"
},
"dependencies": {
"framer-motion": "^12.25.0",
"framer-motion": "^12.26.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
Expand Down
Loading
Loading