JoshWComeau

The Whimsical Potential of JavaScript Frameworks

React Summit US talk

Code Snippets:

SVG egg

Open in CodeSandbox
import React from 'react';
import { useSpring, animated } from 'react-spring';

import {
  getDistanceBetweenPoints,
  convertRadiansToDegrees,
} from './utils';
import useMousePosition from './use-mouse-position';
import useBoundingBox from './use-bounding-box';
import styles from './LikeButton.module.css';

function LikeButton() {
  const mousePosition = useMousePosition();

  const ref = React.useRef();
  const boundingBox = useBoundingBox(ref);

  const bodyRotation = calculateBodyRotation(
    boundingBox,
    mousePosition
  );
  const eyeTranslate = calculateEyePosition(
    boundingBox,
    mousePosition
  );
  const bodyStyle = useSpring({
    transform: `rotate(${bodyRotation}deg)`,
  });
  const eyeTransform = useSpring({
    transform: `translate(
      ${eyeTranslate.x}
      ${eyeTranslate.y}
    )`,
  });

  return (
    <button className={styles.wrapper}>
      <animated.svg
        xmlns="http://www.w3.org/2000/svg"
        ref={ref}
        width="222"
        height="264"
        fill="none"
        viewBox="0 0 222 264"
        style={bodyStyle}
      >
        {/* Egg body */}
        <path
          fill="#EEEDE7"
          d="M222 159c0 72.902-49.696 105-111 105S0 231.902 0 159 49.696 0 111 0s111 86.098 111 159z"
        />

        {/* Eyes */}
        <animated.g {...eyeTransform}>
          <path
            fill="#000"
            d="M99 158.5c0 9.665-7.835 17.5-17.5 17.5S64 168.165 64 158.5 71.835 141 81.5 141 99 148.835 99 158.5z"
          />
          <path
            stroke="#fff"
            strokeLinecap="round"
            strokeOpacity="0.5"
            strokeWidth="4"
            d="M71.317 154.198A8.501 8.501 0 0179.43 148"
          />
          <path
            fill="#000"
            d="M158 158.5c0 9.665-7.835 17.5-17.5 17.5s-17.5-7.835-17.5-17.5 7.835-17.5 17.5-17.5 17.5 7.835 17.5 17.5z"
          />
          <path
            stroke="#fff"
            strokeLinecap="round"
            strokeOpacity="0.5"
            strokeWidth="4"
            d="M130.318 154.198A8.497 8.497 0 01138.43 148"
          />
        </animated.g>

        {/* Mouth */}
        <path
          stroke="#000"
          strokeLinecap="round"
          strokeWidth="8"
          d="M125 191a13.994 13.994 0 01-4.101 9.899 13.994 13.994 0 01-19.798 0A13.996 13.996 0 0197 191"
        />

        {/* Reflections */}
        <g>
          <path
            stroke="#fff"
            strokeLinecap="round"
            strokeWidth="6"
            d="M45 71s0-10.5 15.038-24.897C72.757 33.928 79 33 79 33"
          />
        </g>
      </animated.svg>
    </button>
  );
}

const calculateBodyRotation = (
  boundingBox,
  mousePosition
) => {
  if (
    !boundingBox ||
    typeof mousePosition.x !== 'number' ||
    typeof mousePosition.y !== 'number'
  ) {
    return 0;
  }

  const areaOfEffectRadius =
    boundingBox.height * 2;

  const headCenterPoint = {
    x:
      boundingBox.left + boundingBox.width / 2,
    y:
      boundingBox.top + boundingBox.height / 2,
  };

  const distanceToHead = getDistanceBetweenPoints(
    mousePosition,
    headCenterPoint
  );

  if (distanceToHead > areaOfEffectRadius) {
    return 0;
  }

  const deltaX = headCenterPoint.x - mousePosition.x;
  const deltaY = headCenterPoint.y - mousePosition.y;

  const angleInRads = Math.atan2(deltaY, deltaX);
  const angleInDegrees =
    180 + convertRadiansToDegrees(angleInRads);

  const rotationMax = 10;

  let a, b;
  if (angleInDegrees < 180) {
    a = (rotationMax / 90) * -1;
    b = rotationMax;
  } else {
    a = rotationMax / 90;
    b = rotationMax * -3;
  }

  return a * angleInDegrees + b;
};

function calculateEyePosition(
  boundingBox,
  mousePosition
) {
  if (
    !boundingBox ||
    typeof mousePosition.x !== 'number' ||
    typeof mousePosition.y !== 'number'
  ) {
    return { x: 0, y: 0 };
  }

  const faceCenter = {
    x:
      boundingBox.left +
      boundingBox.width * 0.5,
    y:
      boundingBox.top +
      boundingBox.height * 0.625,
  };

  const relativeMousePosition = {
    x: mousePosition.x - faceCenter.x,
    y: mousePosition.y - faceCenter.y,
  };

  const x = Math.round(relativeMousePosition.x * 0.03);
  const y = Math.round(relativeMousePosition.y * 0.03);

  return { x, y };
}

export default LikeButton;

3D Egg with react-three-fiber

Open in CodeSandbox
import React from 'react';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import {
  OrthographicCamera,
  OrbitControls,
} from '@react-three/drei';
import {
  Canvas,
  useLoader,
  useFrame,
} from '@react-three/fiber';
import { useSpring, animated } from '@react-spring/three';
import { Environment } from '@react-three/drei';

import { getDistanceBetweenPoints } from './utils';
import useMousePosition from './use-mouse-position';
import useBoundingBox from './use-bounding-box';
import styles from './LikeButton.module.css';

function LikeButton() {
  const model = useLoader(GLTFLoader, '/misc/egg.glb');

  const mousePosition = useMousePosition();

  const ref = React.useRef();
  const boundingBox = useBoundingBox(ref);

  const bodyRotation = calculateBodyRotation(
    boundingBox,
    mousePosition
  );
  const eyeTranslate = calculateEyePosition(
    boundingBox,
    mousePosition
  );

  const bodySpring = useSpring({
    rotation: [0, 0, bodyRotation],
  });
  const eyeSpring = useSpring({
    position: [eyeTranslate.x, eyeTranslate.y, 0.9],
  });
  
  const lightSpring = useSpring({
    position: [-15, 15, 5],
    config: {
      stiffness: 200,
      friction: 100,
    }
  });

  return (
    <button ref={ref} className={styles.wrapper}>
      <Canvas style={{ width: '100%', height: '100%' }}>
        <animated.group rotation={bodySpring.rotation}>
          <primitive
            object={model.nodes.Body}
            position={[0, 0, 0]}
          />
          <animated.primitive
            object={model.nodes.Eyes}
            position={eyeSpring.position}
          />
          <primitive
            object={model.nodes.Mouth}
            position={[0, -0.06, 0.8]}
            rotation={[Math.PI * 0.5, 0, 0]}
          />
        </animated.group>
        <OrthographicCamera
          makeDefault
          position={[0, 0, 5]}
          zoom={100}
        />
        <OrbitControls />
        <animated.pointLight intensity={1} {...lightSpring} />
        <Environment files="/misc/warehouse.hdr"  />
      </Canvas>
    </button>
  );
}

const calculateBodyRotation = (
  boundingBox,
  mousePosition
) => {
  if (
    !boundingBox ||
    typeof mousePosition.x !== 'number' ||
    typeof mousePosition.y !== 'number'
  ) {
    return 0;
  }

  const areaOfEffectRadius = boundingBox.height * 2;

  const headCenterPoint = {
    x: boundingBox.left + boundingBox.width / 2,
    y: boundingBox.top + boundingBox.height / 2,
  };

  const distanceToHead = getDistanceBetweenPoints(
    mousePosition,
    headCenterPoint
  );

  if (distanceToHead > areaOfEffectRadius) {
    return 0;
  }

  const deltaX = headCenterPoint.x - mousePosition.x;
  const deltaY = headCenterPoint.y - mousePosition.y;

  const angleInRads = Math.atan2(deltaY, deltaX);

  const rotationMax = Math.PI * 0.025;

  let a = rotationMax / (Math.PI * 0.25);
  let b = rotationMax * -2;
  if (angleInRads < 0) {
    a *= -1;
  }

  return (a * angleInRads + b) * -1;
};

function calculateEyePosition(
  boundingBox,
  mousePosition
) {
  if (
    !boundingBox ||
    typeof mousePosition.x !== 'number' ||
    typeof mousePosition.y !== 'number'
  ) {
    return { x: 0, y: 0 };
  }

  const faceCenter = {
    x: boundingBox.left + boundingBox.width * 0.5,
    y: boundingBox.top + boundingBox.height * 0.625,
  };

  const relativeMousePosition = {
    x: mousePosition.x - faceCenter.x,
    y: mousePosition.y - faceCenter.y,
  };

  const x = (relativeMousePosition.x * 0.03) / 150;
  const y = ((relativeMousePosition.y * 0.03) / 150) * -1;

  return { x, y };
}

export default LikeButton;

Resources: