We were hired to build a wedding website that used watercolor-styled illustrations.
Inspired by a video from Keevin Powel, we decided to try implementing a watercolor reveal animation in Framer.
Obviously, this couldn’t be done directly in Framer (at least not at the time), so the only solution was to build a custom component.
Since we were working in Framer with React, we could replicate the same method Kevin used in his video: using SVG masks to create the effect.
The Concept
The principle is simple: you have an image you want to reveal (we’ll call it the Base Image), and another image that acts as a mask, made up of multiple black-and-white frames stacked horizontally (a spritesheet). This mask will define what parts of the base image are visible at each point in time.
The first frame of the mask is completely black — meaning none of the base image is shown.
Each subsequent frame reveals more of the image by adding more white areas.
The final frame is mostly white, revealing the full base image through the mask.

Simple, right?
1. 📦 Getting things started
We begin by importing some necessary tools from React and Framer Motion:
import { addPropertyControls, ControlType } from "framer"
import { useEffect, useRef } from "react"
import { useAnimate, useInView } from "framer-motion"
import { steps } from "./Steps.tsx"
addPropertyControls
and ControlType
are used to expose customizable props in Framer’s UI.
useRef
and useEffect
are standard React hooks.
useAnimate
gives us imperative animation control.
useInView
allows us to trigger the animation only when the element enters the viewport.
steps()
is a custom easing function that mimics frame-by-frame stepping. We’ll explain it later.
In our case, we used a local "Steps.tsx" file that is basically just a copy of Framer Motion Steps function. We needed to do that because Framer (for some reason) had problems importing the steps function at the time. If you want this file, here it is:
type EasingFunction = (v: number) => number
const clamp = (min: number, max: number, v: number) => {
if (v > max) return max
if (v < min) return min
return v
}
export type Direction = "start" | "end"
export function steps(
numSteps: number,
direction: Direction = "end"
): EasingFunction {
return (progress: number) => {
progress =
direction === "end"
? Math.min(progress, 0.999)
: Math.max(progress, 0.001)
const expanded = progress * numSteps
const rounded =
direction === "end" ? Math.floor(expanded) : Math.ceil(expanded)
return clamp(0, 1, rounded / numSteps)
}
}
2. ⚙️ Variables and Props
For the animation to work correctly it's imperative that the base image's aspect ratio matches that of 1 frame of our mask. So we begin by definining the variables that will store these sizes.
export default function Watercolor_Animation(props) {
const frameWidth = Number(props.width) || 1280
const frameHeight = Number(props.height) || 960
const totalFrames = Number(props.fps) || 12
const duration = Number(props.duration) || 2
const spriteSheetWidth = frameWidth * totalFrames
const mask =
props.mask?.src ??
"https://framerusercontent.com/images/1NuscZlw7HJ91y27X88xglub4UE.webp"
const baseImg =
props.image?.src ??
"https://framerusercontent.com/images/cbZAry5Bz5FQ4NAX6wgdZNXCM.jpg"
frameWidth
/ frameHeight
: dimensions of one frame in the mask.
totalFrames
: how many frames are in the spritesheet.
spriteSheetWidth
: total width of the horizontal spritesheet.
easing
: step-based easing so the mask jumps from frame to frame instead of animating smoothly.
We also define fallback image URLs in case none are passed via props:
We created everything referencing the component's props
so that we don't need to replace them later when we add Framer property controls
3. 🖼️ Building the SVG
SVG is the only element that supports mask
, so we construct our reveal logic inside an SVG block
In our case, the mask we created (very roughly in Photoshop) has 12 frames, each frame is 1280×960 pixels, resulting in a final size of 15360×960 pixels. With this, we configure the viewBox
, width
, and height
accordingly.
Our mask:

This mask can easily be done by going into your prefered graphical editor and downloading a watercolor brush. Just follow these steps:
Paint a 1280x960 (or waterver size you want) layer all black
Duplicate the layer
Use a little bit of the brush
Repeat steps 2 and 3 until you have revealed most of the image
Export all layers as separate pngs
Import them to Figma or Framer
Create a Horizontal Auto-Layout / Stack with the frames ordered sequentially
Export the Auto-Layout / Stack as a jpg or png
Convert it to .webp
With this, you sould be able to create any spritesheet fot his component
Anyways…
Here’s the SVG layout:
<div ref={scope} style={{ width: "100%", height: "100%", display: "flex" }}>
<svg
style={{ width: "100%", aspectRatio: `${frameWidth} / ${frameHeight}` }}
viewBox={`0 0 ${frameWidth} ${frameHeight}`}
preserveAspectRatio="xMidYMid slice"
>
<defs>
<mask id="image-mask" maskUnits="userSpaceOnUse">
<g ref={frameRef}>
<image
href={mask}
width={spriteSheetWidth}
height={frameHeight}
preserveAspectRatio="xMidYMid slice"
/>
</g>
</mask>
</defs>
<image
href={baseImg}
width={frameWidth}
height={frameHeight}
mask="url(#image-mask)"
preserveAspectRatio="xMidYMid slice"
/>
</svg>
</div>
🔍 Key Notes:
<mask>
uses maskUnits="userSpaceOnUse"
so it respects pixel coordinates.
We apply a ref
to the <g>
wrapper (frameRef
) instead of the <image>
itself — this is crucial, because it's what lets us animate the mask.
The base image is revealed through the mask applied to it.
4. 🏃 Triggering the Animation
We want the animation to play once, only when the component scrolls into view.
const [scope, animate] = useAnimate()
const frameRef = useRef(null)
const isInView = useInView(scope, { once: true })
useEffect(() => {
if (isInView && frameRef.current) {
animate(
frameRef.current,
{ x: -frameWidth * (totalFrames - 1) },
{ duration: duration, ease: easing }
)
}
}, [isInView])
🧠 How it works:
scope
is the outer div reference used by useInView
.
frameRef
points to the <g>
element that holds the mask <image>
.
Once in view, we animate the mask to the left by its total width.
The steps()
easing makes it snap between frames — like a flipbook.
5. 🎛️ Making It Editable in Framer
We expose all the key parameters using addPropertyControls()
:
addPropertyControls(Watercolor_Animation, {
image: {
title: "Image",
type: ControlType.ResponsiveImage,
},
duration: {
title: "Duration",
type: ControlType.Number,
defaultValue: 2,
min: 0.1,
max: 100,
},
mask: {
title: "Mask",
type: ControlType.ResponsiveImage,
},
fps: {
title: "FPS",
type: ControlType.Number,
defaultValue: 12,
min: 1,
max: 50,
},
width: {
title: "Mask Frame Width",
type: ControlType.Number,
defaultValue: 1280,
min: 1,
max: 5000,
},
height: {
title: "Mask Frame Height",
type: ControlType.Number,
defaultValue: 960,
min: 1,
max: 5000,
},
})
This makes the component fully customizable from within Framer’s right-hand panel.
✅ Final Thoughts
This solution gives you fine-grained control over animations that are not natively supported in Framer. You can go beyond reveals and create custom wipe effects, loading animations, or dynamic transitions with just one clever SVG mask.
Here's the final code:
import { addPropertyControls, ControlType } from "framer"
import { useEffect, useRef } from "react"
import { useAnimate, useInView } from "framer-motion"
import { steps } from "./Steps.tsx"
export default function Watercolor_Animation(props) {
const [scope, animate] = useAnimate()
const frameRef = useRef(null)
const isInView = useInView(scope, { once: true })
const frameWidth = Number(props.width) || 1280
const frameHeight = Number(props.height) || 960
const totalFrames = Number(props.fps) || 12
const duration = Number(props.duration) || 2
const spriteSheetWidth = frameWidth * totalFrames
const easing = steps(totalFrames - 1)
const mask =
props.mask?.src ??
"https://framerusercontent.com/images/1NuscZlw7HJ91y27X88xglub4UE.webp"
const baseImg =
props.image?.src ??
"https://framerusercontent.com/images/cbZAry5Bz5FQ4NAX6wgdZNXCM.jpg"
useEffect(() => {
if (isInView && frameRef.current) {
animate(
frameRef.current,
{ x: -frameWidth * (totalFrames - 1) },
{ duration: duration, ease: easing }
)
}
}, [isInView])
return (
<div
ref={scope}
style={{
width: "100%",
height: "100%",
display: "flex",
}}
>
<svg
style={{
width: "100%",
aspectRatio: `${frameWidth} / ${frameHeight}`,
}}
viewBox={`0 0 ${frameWidth} ${frameHeight}`}
preserveAspectRatio="xMidYMid slice"
>
<defs>
<mask id="image-mask" maskUnits="userSpaceOnUse">
<g ref={frameRef}>
<image
href={mask}
width={spriteSheetWidth ?? 15360}
height={frameHeight}
preserveAspectRatio="xMidYMid slice"
/>
</g>
</mask>
</defs>
<image
href={baseImg}
width={frameWidth}
height={frameHeight}
mask="url(#image-mask)"
preserveAspectRatio="xMidYMid slice"
/>
</svg>
</div>
)
}
Watercolor_Animation.defaultProps = {
image: "https://framerusercontent.com/images/cbZAry5Bz5FQ4NAX6wgdZNXCM.jpg",
duration: 2,
mask: "https://framerusercontent.com/images/1NuscZlw7HJ91y27X88xglub4UE.webp",
fps: 12,
width: 1280,
height: 960,
}
addPropertyControls(Watercolor_Animation, {
image: {
title: "Image",
type: ControlType.ResponsiveImage,
},
duration: {
title: "Duration",
type: ControlType.Number,
defaultValue: 2,
min: 0.1,
max: 100,
},
mask: {
title: "Mask",
type: ControlType.ResponsiveImage,
},
fps: {
title: "FPS",
type: ControlType.Number,
defaultValue: 12,
min: 1,
max: 50,
},
width: {
title: "Mask Frame Width",
type: ControlType.Number,
defaultValue: 1280,
min: 1,
max: 5000,
},
height: {
title: "Mask Frame Height",
type: ControlType.Number,
defaultValue: 960,
min: 1,
max: 5000,
},
})
Here's the Remix Link
If you're curious about how to crete a functional Carousel in Framer that actually interacts with Framer's CMS, we will be discussing that on the next post