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
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
}