How we created a watercolor animation component for framer

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:

/*
  Create stepped version of 0-1 progress

  @param [int]: Number of steps
  @param [number]: Current value
  @return [number]: Stepped value
*/

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

//imgs srcs
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:

  1. Paint a 1280x960 (or waterver size you want) layer all black

  2. Duplicate the layer

  3. Use a little bit of the brush

  4. Repeat steps 2 and 3 until you have revealed most of the image

  5. Export all layers as separate pngs

  6. Import them to Figma or Framer

  7. Create a Horizontal Auto-Layout / Stack with the frames ordered sequentially

  8. Export the Auto-Layout / Stack as a jpg or png

  9. 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

Darwin Agency © 2025 Todos os direitos reservados.

Darwin Agency © 2025
Todos os direitos reservados.