Building an Elastic Curve Preloader with Framer Motion in Next.js

Ahmad NegmSep 2026·Ahmad Negm

Building an Elastic Curve Preloader with Framer Motion in Next.js

Most page loaders are just a spinner or a progress bar. I wanted the EGLinks loader to double as a page transition, something that covers the screen, shows the logo, then peels away in a way that feels deliberate instead of just "loading finished, here's your content."

The result is a full-screen curtain whose bottom edge is a curve instead of a straight line. As it slides away, that curve sags for a moment before flattening out, which is what gives it that liquid, elastic feel instead of a flat mechanical wipe.

Here's how it's built.

Step 1: A full-screen curtain that knows how to slide away

The base is a fixed, full-viewport div that sits above everything else:

code
<motion.div
  variants={slideUp}
  initial="initial"
  exit="exit"
  className='w-screen h-screen bg-[#222222] fixed left-0 top-0 z-999'
>

slideUp is a variant defined elsewhere in the project. Its job is simple: hold the curtain in place on initial, then translate it up and off the top of the screen on exit. Everything else in this component (the logo, the curve) lives inside this div, so it all moves together when the slide happens.

This component only does anything visually interesting on mount and unmount, so it needs to sit inside an AnimatePresence further up the tree to actually animate on route changes rather than just popping in and out.

Step 2: Reading the viewport size (and why it can't happen on the server)

The curve's shape depends on the actual pixel width and height of the screen, and in Next.js, window doesn't exist during server rendering. So the dimensions get grabbed after mount instead:

code
const [dimension, setDimension] = useState({ width: 0, height: 0 })

useEffect(() => {
  setDimension({ width: window.innerWidth, height: window.innerHeight })
}, [])

Everything that depends on dimension is guarded behind dimension.width > 0, so nothing tries to render with a width of zero before that effect has run.

Step 3: Two SVG paths, one flat, one drooping

The curtain's shape is one SVG <path>, and its bottom edge is built with a quadratic Bezier curve (the Q command), not a straight line:

code
const initialPath = `M0 0 L${dimension.width} 0 L${dimension.width} ${dimension.height} Q${dimension.width / 2} ${dimension.height + 300} 0 ${dimension.height} L0 0`
const targetPath = `M0 0 L${dimension.width} 0 L${dimension.width} ${dimension.height} Q${dimension.width / 2} ${dimension.height} 0 ${dimension.height} L0 0`

Both paths trace the same rectangle: top-left, top-right, down to bottom-right, then a curve across to bottom-left, then back up to close it. The only difference is the Y position of the curve's control point.

In initialPath, the control point sits 300px below the bottom edge, which pulls the curve into a deep sag beneath the visible screen. In targetPath, the control point sits exactly on the bottom edge, which flattens the curve into a straight line. That's why the SVG itself is given extra height:

code
<svg className='absolute top-0 w-full h-[calc(100%+300px)]'>

Without that 300px of extra room, the sagging curve in initialPath would just get clipped off and never show up.

Step 4: Making the flattening lag behind the slide

This is the part that actually sells the effect. The curve has its own set of variants, separate from the curtain's slide:

code
const curve = {
  initial: {
    d: initialPath,
    transition: { duration: 0.7, ease: [0.76, 0, 0.24, 1] }
  },
  exit: {
    d: targetPath,
    transition: { duration: 0.7, ease: [0.76, 0, 0.24, 1], delay: 0.3 }
  }
}

Notice the delay: 0.3 on the exit transition. The curtain's slide-up starts the instant the exit animation triggers, but the curve doesn't start flattening until 0.3 seconds in. For that brief window, the curtain is already moving while its bottom edge is still sagging, so as the whole shape passes through the viewport, the wave becomes visible first, and only straightens out to a clean edge as it finishes leaving. That short offset between the two animations is what reads as elastic rather than robotic. Without it, the slide and the curve would move in lockstep and the whole thing would just look like a straight panel sliding up.

The easing curve [0.76, 0, 0.24, 1] is a steep ease-in-out: slow at the very start and end, fast through the middle, which keeps the motion feeling snappy instead of linear.

Step 5: Anchoring the logo so it doesn't move

The logo sits on top of the curve, centered, and completely unaffected by the path morphing underneath it:

code
<AnimatedLogo className='absolute z-10 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 max-w-[400px] w-[90%] aspect-square dark:invert-0 invert' />

It's positioned independently with its own transform, sitting at z-10 above the SVG, so while the curtain slides and the curve reshapes itself, the logo just holds still in the center the whole time. That contrast, one static anchor point against all that motion around it, is part of what keeps the animation from feeling chaotic.

Where it landed

This runs on every route change across EGLinks now. The whole effect comes down to two ideas worth reusing anywhere: build the curtain's edge as a shape you can morph instead of a straight line, and stagger two related animations slightly instead of running them in perfect sync. That small offset in timing is doing most of the emotional work here, not the curve itself.

Comments