Experiments

About

Back

An Experiment In Toggle Switch Animation

July 2023

  • CSS

  • Javascript

  • Framer Motion

Here's a fun animation that I created for a Toggle Switch component with some basic styling. I took inspiration from a great twitter post by Derek Briggs.

The animation is created with Framer Motion. Code snippet to follow.

Give it a try!

Code:

import React, { useState } from "react";
import { cubicBezier, useAnimate } from "framer-motion";
import { Check, IconWeight, Minus } from "@phosphor-icons/react";
import { FontWeight } from "next/dist/compiled/@vercel/og/satori";

function ToggleSwitch() {
  const [isChecked, setIsChecked] = useState(false);
  const [thumbScope, thumbAnimate] = useAnimate();
  const [iconScope, iconAnimate] = useAnimate();

  const customEaseIn = cubicBezier(0.47, 0.15, 0.86, 0.55);
  const customEaseOut = cubicBezier(0.13, 0.47, 0.52, 0.9);

  const handleIconLeave = async () => {
    await iconAnimate(
      iconScope.current,
      {
        opacity: 0,
        scale: 0.5,
        rotate: 30,
      },
      {
        duration: 0.12,
        ease: customEaseIn,
      }
    );
  };
  const handleIconEnter = async () => {
    await iconAnimate(
      iconScope.current,
      {
        opacity: 1,
        scale: 1,
        rotate: 0,
      },
      {
        duration: 0.12,
        ease: customEaseOut,
      }
    );
  };

  const handleChange = async (checked: boolean) => {
    await handleIconLeave();
    await thumbAnimate(
      thumbScope.current,
      {
        width: "100%",
        backgroundColor: "#AEB7C3",
      },
      { duration: 0.18, ease: customEaseIn }
    );

    setIsChecked(checked);

    await thumbAnimate(
      thumbScope.current,
      {
        width: "auto",
        backgroundColor: checked ? "#64748b" : "#f8fafc",
      },
      {
        duration: 0.18,
        ease: customEaseOut,
      }
    );
    handleIconEnter();
  };

  const commonIconProps: { weight: IconWeight; size: number } = {
    weight: "bold",
    size: 16,
  };
  return (
    <div
      className={`bg-slate-200 border-slate-300 rounded-full w-14 h-8 p-1 flex relative cursor-pointer ${
        isChecked ? "justify-end" : "justify-start"
      }`}
      onClick={() => handleChange(!isChecked)}
    >
      <div
        ref={thumbScope}
        className={`${
          isChecked ? "bg-slate-500" : "bg-slate-50"
        } rounded-full z-10 transition-transform`}
      >
        <div
          ref={iconScope}
          className="h-6 w-6 flex items-center justify-center"
        >
          {isChecked ? (
            <Check
              className="text-slate-100 cursor-pointer"
              {...commonIconProps}
            />
          ) : (
            <Minus
              className="text-slate-400 cursor-pointer"
              {...commonIconProps}
            ></Minus>
          )}
        </div>
      </div>

      <input
        id="checkbox-test"
        className="opacity-0 absolute top-0 left-0 w-full h-full cursor-pointer"
        type="checkbox"
        aria-label="toggle switch"
        data-checked={isChecked}
        checked={isChecked}
      />
    </div>
  );
}

export default ToggleSwitch;