Skip to content

Published

Re-creating the Next.js TiltCard

I found this 3D-animated panel at the official website for Next.js, and it was so cool that I knew I had to try and re-create it.

Here's the result:

Tip: try hovering over the card with your mouse and moving it around!

The process was quite involved, and here I'll explain how I went about it.

Base styles

Here's what we're starting with. Implementing this mostly involves CSS fundamentals, so I won't spend much time lingering on it in this blog post.

Stylistic flourishes

There are a few static differences between our work-in-progress panel and the original:

  1. The blue gradient effect behind the icon near the top
  2. Text gradients for the title and subtitle

First, we'll add a <div> element representing the blue gradient effect and define a CSS variable --accent-color — which will be used as the accent color of the gradient — on the root element:

function TiltCard({
  href,
  icon,
  title,
  subTitle,
  paragraph,
  buttonText,
  accentColor = '#ffffff',
}) {
  return (
    <a
      className={styles.root}
      style={{ '--accent-color': accentColor }}
      href={href}
      rel="noopener noreferrer"
      target="_blank"
    >
      <div className={styles.blob} />
      <div className={styles.featuredWrapper}>
        {icon}
        <div className={styles.announcementSubtext}>
          <h2 className={styles.announcementTitle}>{title}</h2>
          <span className={styles.announcementSubtitle}>{subTitle}</span>
          <p>{paragraph}</p>
        </div>
        <button className={styles.button}>
          {buttonText} <GeistIcon />
        </button>
      </div>
    </a>
  )
}

(I'm setting --accent-color in the style prop of <a> so that the color can be passed as a prop to <TiltCard> to be reused for the mouse-following glow effect later.)

We don't want the gradient effect to break the flow of the rest of the panel, so we'll need to absolutely position it (while setting the root element as its "anchor" by setting position: relative). We also need to set overflow: hidden on the root element so that the gradient doesn't leak out.

.blob {
  width: 350px;
  height: 350px;
  position: absolute;
  inset: 0 0 auto;
  margin: 0 auto;
  border-radius: 9999px;
  transform: scale(1.8);
  opacity: 0.15;
  background: radial-gradient(
    circle,
    var(--accent-color) 0,
    rgba(161, 252, 70, 0) 71%
  );
}

/* ... */

.root {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  padding: 24px;
  box-shadow:
    0 0 0 1px rgba(0, 0, 0, 0.07),
    0 2px 4px rgba(0, 0, 0, 0.05),
    0 12px 24px rgba(0, 0, 0, 0.05);
  border-radius: 8px;
  cursor: pointer;
  text-align: center;
  text-decoration: none;
  position: relative;
  overflow: hidden;
}

Next is the text gradient. This one involves kind of a hacky solution since the CSS color attribute doesn't let you set gradients (which are actually considered a type of image). Instead, we'll set the background to display the gradient, clip it so that it's only drawn where the text is drawn, and make the text transparent:

.announcementTitle {
  font-size: 64px;
  line-height: 72px;
  font-family: Inter;
  margin: 0;
  white-space: nowrap;
  background: linear-gradient(180deg, #555, #000);
  background-clip: text;
  -webkit-text-fill-color: transparent;
}

.announcementSubtitle {
  font-size: 14px;
  font-family: Inter;
  font-weight: 500;
  margin-bottom: 16px;
  background: linear-gradient(180deg, #555, #000);
  background-clip: text;
  -webkit-text-fill-color: transparent;
}

The website of Next.js uses -webkit-text-fill-color, though a similar effect can be achieved by just using color since -webkit-text-fill-color defaults to currentColor (which is set by color). I'm not entirely sure why -webkit-text-fill-color is preferred here. My best guess is that it's for preventing issues where non-text inline elements, such as SVG, are embedded in the title.

Glow effect

Part of the illusion that the panel is rotating in 3D is the glow effect that makes it look as though the panel is a semi-reflective surface. Since the panel will rotate based on the mouse position, we can calculate the position of the glow from the mouse position as well. A naive implementation using React hooks to find the mouse's position on the panel is as follows:

function useMousePosition(ref) {
  const [position, setPosition] = React.useState({ x: null, y: null })
  React.useEffect(() => {
    const updatePosition = (ev) => {
      setPosition({ x: ev.offsetX, y: ev.offsetY })
    }
    ref.current.addEventListener('mousemove', updatePosition)
    return () => {
      ref.current.removeEventListener('mousemove', updatePosition)
    }
  }, [ref.current])
  return position
}

This hook does work (as long as you set pointer-events: none to every child element), but it makes the CSS animations we'll add later quite laggy. A better implementation can be found in the @react-hook/mouse-position package, which implements throttling that prevents the component from being rerendered too frequently, leading to smoother animation.

Now, it may not be obvious, but the glow is actually offset from the mouse position based on the distance the mouse is from the center. One way we can calculate the correct position for the glow is the following:

glowX = (x - centerX) * SCALE + centerX
glowY = (y - centerY) * SCALE + centerY

If SCALE = 2, this makes the glow twice as far away from the mouse as the mouse is from the center.

With this knowledge, we can calculate the glow styles dynamically with a React hook:

function useTiltStyle(ref, accentColor) {
  const mouse = useMouse(ref)
  const { elementWidth, elementHeight } = mouse
  const center = { x: elementWidth / 2, y: elementHeight / 2 }

  const SCALE = 2
  const glow = {
    x: (mouse.x - center.x) * SCALE + center.x,
    y: (mouse.y - center.y) * SCALE + center.y,
  }

  return {
    '--glow-bg':
      `radial-gradient(` +
      `circle at ${glow.x}px ${glow.y}px ${accentColor}55, #0000000f` +
      `)`,
    '--glow-opacity': mouse.isOver ? 0.3 : 0,
  }
}

...and put it to use like so:

.glow {
  position: absolute;
  inset: 0;
  background-image: var(--glow-bg);
  opacity: var(--glow-opacity);
  transition: opacity 0.3s ease;
  mix-blend-mode: plus-lighter;
  z-index: 2;
}
function TiltCard({
  href,
  icon,
  title,
  subTitle,
  paragraph,
  buttonText,
  accentColor = '#ffffff',
}) {
  const rootRef = React.useRef(null)
  const rootStyle = useTiltStyle(rootRef, accentColor)

  return (
    <a
      ref={rootRef}
      className={styles.root}
      style={{
        '--accent-color': accentColor,
        ...rootStyle,
      }}
      href={href}
      rel="noopener noreferrer"
      target="_blank"
    >
      <div className={styles.blob} />
      <div className={styles.featuredWrapper}>
        {icon}
        <div className={styles.announcementSubtext}>
          <h2 className={styles.announcementTitle}>{title}</h2>
          <span className={styles.announcementSubtitle}>{subTitle}</span>
          <p>{paragraph}</p>
        </div>
        <button className={styles.button}>
          {buttonText} <GeistIcon />
        </button>
      </div>
      <div className={styles.glow} />
    </a>
  )
}

3D tilt

Now comes the hardest part: the panel must rotate away from the mouse. (Or towards it? ...you know what I mean.) This part takes some knowledge of linear algebra to get right.

Rotation of an object in 3D space is defined along a vector with its origin at the center representing the "axis of rotation", with an angle representing how much it will rotate. I won't go into too much detail since MDN can explain it much better than me, but we will need to find a vector on the x-y plane (i.e. the screen) that is orthogonal to the vector created by drawing a line from the center of the panel to the mouse position. Once we find such a vector, we can rotate the panel 0-6° along the axis of rotation, with the magnitude depending on the distance between the mouse and the center.

Fortunately for us, we don't have to do any complicated trigonometry to find an orthogonal vector in 2D space. All we have to do is switch the x and y coordinates of the mouse position and make one of them negative, like so:

[x, y] -> [y, -x]

Calculating the angle is slightly more tricky. We need it to get bigger the farther the mouse is from the center, so we'll calculate the distance using the formula for the hypotenuse of a triangle (JavaScript does this for us with Math.hypot()). Then we divide it by a constant to get a value between 0 and 1 (approximately), and multiply it by 6 to get a value between 0 and 6 (approximately).

However, we're not done yet. Since the panel is rectangular, one side will (usually) be shorter than the other and so we want rotations in the direction of the shorter side to be more sensitive, so that we still get a 0-6° rotation on either axis. To account for this, we'll scale one of the coordinates of the mouse's position from the origin by the ratio of the lengths of the two sides of the panel.

ratio = height / width
angle = 6 * hypot(x * ratio, y) / centerY

Finally, there's a bit of CSS we'll add to improve the animation. transition: transform 0.3s ease-out applies a 0.3 second "ease-out" timing to the rotation, and will-change: transform prevents the text from jumping on mouse hover. Additionally, applying perspective: 1500px to the panel's parent gives the animation more depth.

Putting it all together:

function useTiltStyle(ref, accentColor) {
  const mouse = useMouse(ref)
  const { elementWidth, elementHeight } = mouse
  const center = { x: elementWidth / 2, y: elementHeight / 2 }
  const mouseFromOrigin = {
    x: mouse.x - center.x,
    y: mouse.y - center.y,
  }

  const SCALE = 2
  const glow = {
    x: (mouse.x - center.x) * SCALE + center.x,
    y: (mouse.y - center.y) * SCALE + center.y,
  }

  const axisOfRotation = {
    x: mouseFromOrigin.y,
    y: -mouseFromOrigin.x,
  }
  const ratio = elementHeight / elementWidth
  const angle =
    (6 * Math.hypot(mouseFromOrigin.x * ratio, mouseFromOrigin.y)) / center.y
  const rotate3d = `rotate3d(${axisOfRotation.x}, ${axisOfRotation.y}, 0, ${angle}deg)`

  return {
    '--glow-bg':
      `radial-gradient(` +
      `circle at ${glow.x}px ${glow.y}px ${accentColor}55, #0000000f` +
      `)`,
    '--glow-opacity': mouse.isOver ? 0.3 : 0,
    transform: mouse.isOver
      ? `scale3d(1.01, 1.01, 1.01) ${rotate3d}`
      : undefined,
    transition: 'transform 0.3s ease-out',
    'will-change': 'transform',
  }
}

...and voilà!

We can re-use the same component for the other two tilt-cards featured on Next.js's site:

Accessibility

For many people, certain kinds of motion can trigger physical symptoms like nausea, dizziness, and malaise — up to 35% of adults 40+ in the US have experienced some form of this! Operating systems allow users to enable a "reduced-motion" mode which can remedy this issue by disabling some animations.

To hook into this functionality, web app authors can use the prefers-reduced-motion media query. For instance, one can imagine a button that expands upon mouse hover, with CSS something like:

.button {
  transform: scale(1);
}

.button:hover {
  transform: scale(1.2);
}

@media (prefers-reduced-motion: no-preference) {
  .button {
    transition: transform 300ms;
  }
}

This disables the animation for those who have the reduce-motion feature enabled in their operating system settings.

We can implement this in our useTiltStyle hook with @react-hook/media-query like so:

function useTiltStyle(ref, accentColor) {
  const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion)')
  const mouse = useMouse(ref)
  const { elementWidth, elementHeight } = mouse
  const center = { x: elementWidth / 2, y: elementHeight / 2 }
  const mouseFromOrigin = {
    x: mouse.x - center.x,
    y: mouse.y - center.y,
  }

  const SCALE = 2
  const glow = {
    x: (mouse.x - center.x) * SCALE + center.x,
    y: (mouse.y - center.y) * SCALE + center.y,
  }

  const axisOfRotation = {
    x: mouseFromOrigin.y,
    y: -mouseFromOrigin.x,
  }
  const ratio = elementHeight / elementWidth
  const angle =
    (6 * Math.hypot(mouseFromOrigin.x * ratio, mouseFromOrigin.y)) / center.y
  const rotate3d = `rotate3d(${axisOfRotation.x}, ${axisOfRotation.y}, 0, ${angle}deg)`

  return {
    '--glow-bg':
      `radial-gradient(` +
      `circle at ${glow.x}px ${glow.y}px ${accentColor}55, #0000000f` +
      `)`,
    '--glow-opacity': mouse.isOver ? 0.3 : 0,
    transform:
      mouse.isOver && !prefersReducedMotion
        ? `scale3d(1.01, 1.01, 1.01) ${rotate3d}`
        : undefined,
    transition: prefersReducedMotion ? undefined : 'transform 0.3s ease-out',
    'will-change': prefersReducedMotion ? undefined : 'transform',
  }
}

  • CSS
  • React