Building Capital.xyz in Framer

Root
Work

Layout

The most interesting part of building this is the code that I walk through below. But in order for this to make sense it’s helpful to understand how the page is laid out for the sticky animation.

image

So basically we have four hidden triggers on the page. We reference these triggers inside of Framer’s scroll animations to cross fade the videos and reveal the section’s text. We use these triggers in the code as well, for the nav and videos, as we’ll see in a second.

Instead of absolutely positioning these in the canvas itself, I left them relatively positioned to make editing easier, and added a code override to each step so that it absolutely positions itself when published:

export function withPositionAbsolute(Component): ComponentType {
    return (props) => {
        return (
            <Component
                {...props}
                style={{ position: "absolute", left: 0, right: 0, bottom: 0 }}
            />
        )
    }
}

Nav tabs

The most code heavy part of doing a page like this in Framer is the nav tabs. This is a brilliant example of what I’m coming to love about Framer, though, which is the inoperability of code and canvas. The rest of the site is designed directly in the canvas, but the nav tab components are written in code. However, we can still setup those components via the canvas, passing in props for the icons, where they links to, etc. In the component, we can define propertyControls that render as inputs directly in the Framer canvas:

image

There were two tricks to the NavTabs that I’ll call out.

Active State

/* When target is in view, set state to active */
const observer = new IntersectionObserver(
    (entries) => {
        entries.forEach((entry) => {
            if (entry.isIntersecting) {
                setActive(true)
            } else {
                setActive(false)
            }
        })
    },
    {
		/* when the section hits the center of the screen */
        rootMargin: "-50% 0px -50% 0px",
    }
)

/* re-use Framer's link prop to target the section we're observing */
const targetSelector = props.link.replace("/", "")

useEffect(() => {
    if (typeof window !== "undefined") {
        try {
            const target = document.querySelector(targetSelector)
            if (target) {
                observer.observe(target)
                return () => observer.unobserve(target)
            }
        } catch (err) {
            console.warn(`Error observing nav tab link: ${props.link}`)
        }
    }
}, [])

Having the active state let’s us animate between the default tab and active tab, and conditionally show the underline, which brings me to my second point.

Layout Effects with Initial Animations

The trickiest part of building this page was the underline that follows the active tab:

image

To be more specific, the hardest part was figuring out how to add the initial and exit animations, were you see the underline grow as it appears beneath the first link, and shrink away after the final tab.

It’s actually quite easy to get the underline to slide left and right toward the active tab, thanks to Framer Motion’s shared layout animations. All it takes is adding layoutId="underline" to the <motion.div> we use for the underline.

For the grow and shrink effect, I figured it out with dynamic variants. The first trick was to avoid animating width as that messes with layout animations, and instead animate clip-path. The second trick was to use dynamic variants. Looking at the video above, we notice that only the first tab’s underline should have an initial animation, and only the last tab’s underline should have an exit animation. By reflecting that in the variants, we nail it:

<AnimatePresence>
  {active && (
      <motion.div
          key={props.link}
          layoutId="underline"
					/* If we're rendering the first or last tab, we animate the underline's entrance and exit */
          custom={{
              animate:
                  props.link === "/#trigger-1" ||
                  props.link === "/#trigger-4",
          }}
          variants={{
              initial: ({ animate }) => {
                  return {
                      clipPath: animate
                          ? "inset(0% 50% 0% 50%)"
                          : "inset(0% 0% 0% 0%)",
                  }
              },
              active: {
                  clipPath: "inset(0% 0% 0% 0%)",
              },
              exit: ({ animate }) => {
                  return {
                      clipPath: animate
                          ? "inset(0% 50% 0% 50%)"
                          : "inset(0% 0% 0% 0%)",
                  }
              },
          }}
          initial="initial"
          animate="active"
          exit="exit"
					...

Restarting Videos

Thankfully the default Framer video component works perfectly for our sticky scrolling media, except for one small detail. Because the videos are positioned absolutely on top of each other, we can’t control they’re play and pause state based on when they’re in view, as they’re always in view. Instead, we need to play them when their associated trigger comes in view. This was relatively easy by adding a hook to the parent container that locates the nested video and replays it whenever the trigger comes back in view:

export function withVideoOrchestration(Component): ComponentType {
    return (props) => {
        useEffect(() => {
            /* Logic here to find the videos and restart them when their associated triggers come in view */
            if (typeof window !== "undefined") {
                const steps = document.querySelectorAll(
                    '[data-framer-name*="Step"]'
                )
                steps.forEach((step, index) => {
                    const video = step.querySelector("video")
                    const trigger = document.querySelector(
                        `#trigger-${index + 1}`
                    )

                    const observer = new IntersectionObserver(
                        (entries) => {
                            entries.forEach((entry) => {
																/* This is the important part. When the section begins, start the video from the beginning */
                                if (entry.isIntersecting) {
                                    video.currentTime = 0
                                    video.play()
                                }
                            })
                        },
                        {
                            rootMargin: "-50% 0px -50% 0px",
                        }
                    )

                    observer.observe(trigger)
                })
            }
        }, [window])

        return <Component {...props} />
    }
}