loading
Just a moment.

Curved Tab Bar

November 2024


import { motion } from 'framer-motion';
import React, { useState } from 'react';

interface IconProps {
  className?: string;
}

const HomeIcon = (props: IconProps) => {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      fill="none"
      viewBox="0 0 24 24"
      strokeWidth={1.5}
      stroke="currentColor"
      className={props.className}
    >
      <g fill="none" stroke="white" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5">
        <path d="M6.133 21C4.955 21 4 20.02 4 18.81v-8.802c0-.665.295-1.295.8-1.71l5.867-4.818a2.09 2.09 0 0 1 2.666 0l5.866 4.818c.506.415.801 1.045.801 1.71v8.802c0 1.21-.955 2.19-2.133 2.19z" />
        <path d="M9.5 21v-5.5a2 2 0 0 1 2-2h1a2 2 0 0 1 2 2V21" />
      </g>
    </svg>
  );
}

const GearIcon = (props: IconProps) => {
  return (
    <svg className={props.className} xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
      <g fill="none" stroke="white" strokeWidth="1.5">
        <circle cx="12" cy="12" r="3" />
        <path d="M13.765 2.152C13.398 2 12.932 2 12 2s-1.398 0-1.765.152a2 2 0 0 0-1.083 1.083c-.092.223-.129.484-.143.863a1.62 1.62 0 0 1-.79 1.353a1.62 1.62 0 0 1-1.567.008c-.336-.178-.579-.276-.82-.308a2 2 0 0 0-1.478.396C4.04 5.79 3.806 6.193 3.34 7s-.7 1.21-.751 1.605a2 2 0 0 0 .396 1.479c.148.192.355.353.676.555c.473.297.777.803.777 1.361s-.304 1.064-.777 1.36c-.321.203-.529.364-.676.556a2 2 0 0 0-.396 1.479c.052.394.285.798.75 1.605c.467.807.7 1.21 1.015 1.453a2 2 0 0 0 1.479.396c.24-.032.483-.13.819-.308a1.62 1.62 0 0 1 1.567.008c.483.28.77.795.79 1.353c.014.38.05.64.143.863a2 2 0 0 0 1.083 1.083C10.602 22 11.068 22 12 22s1.398 0 1.765-.152a2 2 0 0 0 1.083-1.083c.092-.223.129-.483.143-.863c.02-.558.307-1.074.79-1.353a1.62 1.62 0 0 1 1.567-.008c.336.178.579.276.819.308a2 2 0 0 0 1.479-.396c.315-.242.548-.646 1.014-1.453s.7-1.21.751-1.605a2 2 0 0 0-.396-1.479c-.148-.192-.355-.353-.676-.555A1.62 1.62 0 0 1 19.562 12c0-.558.304-1.064.777-1.36c.321-.203.529-.364.676-.556a2 2 0 0 0 .396-1.479c-.052-.394-.285-.798-.75-1.605c-.467-.807-.7-1.21-1.015-1.453a2 2 0 0 0-1.479-.396c-.24.032-.483.13-.82.308a1.62 1.62 0 0 1-1.566-.008a1.62 1.62 0 0 1-.79-1.353c-.014-.38-.05-.64-.143-.863a2 2 0 0 0-1.083-1.083Z" />
      </g>
    </svg>
  )
}

const MagnifyingGlassIcon = (props: IconProps) => {
  return (
    <svg className={props.className} xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
      <path fill="none" stroke="white" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M19 11.5a7.5 7.5 0 1 1-15 0a7.5 7.5 0 0 1 15 0m-2.107 5.42l3.08 3.08" />
    </svg>
  )
}

const MessagesIcon = (props: IconProps) => {
  return (
    <svg className={props.className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
      <g fill="none" stroke="white">
        <rect width="16" height="12" x="4" y="6" rx="2" />
        <path d="m4 9l7.106 3.553a2 2 0 0 0 1.788 0L20 9" />
      </g>
    </svg>
  )
}

const HeartIcon = (props: IconProps) => {
  return (
    <svg className={props.className} xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
      <path fill="white" fillRule="evenodd" d="M5.624 4.424C3.965 5.182 2.75 6.986 2.75 9.137c0 2.197.9 3.891 2.188 5.343c1.063 1.196 2.349 2.188 3.603 3.154q.448.345.885.688c.526.415.995.778 1.448 1.043s.816.385 1.126.385s.674-.12 1.126-.385c.453-.265.922-.628 1.448-1.043q.437-.344.885-.687c1.254-.968 2.54-1.959 3.603-3.155c1.289-1.452 2.188-3.146 2.188-5.343c0-2.15-1.215-3.955-2.874-4.713c-1.612-.737-3.778-.542-5.836 1.597a.75.75 0 0 1-1.08 0C9.402 3.882 7.236 3.687 5.624 4.424M12 4.46C9.688 2.39 7.099 2.1 5 3.059C2.786 4.074 1.25 6.426 1.25 9.138c0 2.665 1.11 4.699 2.567 6.339c1.166 1.313 2.593 2.412 3.854 3.382q.43.33.826.642c.513.404 1.063.834 1.62 1.16s1.193.59 1.883.59s1.326-.265 1.883-.59c.558-.326 1.107-.756 1.62-1.16q.396-.312.826-.642c1.26-.97 2.688-2.07 3.854-3.382c1.457-1.64 2.567-3.674 2.567-6.339c0-2.712-1.535-5.064-3.75-6.077c-2.099-.96-4.688-.67-7 1.399" clipRule="evenodd" />
    </svg>
  )
}

const TABS = [
  { icon: HomeIcon, label: 'Home' },
  { icon: GearIcon, label: 'Settings' },
  { icon: MagnifyingGlassIcon, label: 'Search' },
  { icon: MessagesIcon, label: 'Messages' },
  { icon: HeartIcon, label: 'Favorites' },
];

const linePath = 'M0,40 Q172,10 344,40';
const motionPath = `path("M0,40 Q172,10 344,40")`;
const motionHorizontalPadding = 12;

export default function CurvedTabBar() {
  const [activeTab, setActiveTab] = useState(1);
  const offsetDistance = `${motionHorizontalPadding +
    (activeTab / (TABS.length - 1)) * (100 - motionHorizontalPadding * 2)
    }%`;

  return (
    <div className="relative flex h-[600px] w-full flex-col items-center justify-center overflow-hidden">
      <div
        className="relative -mt-[150%] w-full overflow-hidden bg-background sm:-mt-[100%]"
        style={{
          height: '704px',
          borderRadius: '54px',
          width: '344px',
          boxShadow: '0 0 0 12px #000',
        }}
      >
        <div className="absolute bottom-0 flex h-[100px] w-full flex-col justify-between overflow-hidden">
          <svg
            xmlns="http://www.w3.org/2000/svg"
            width="344"
            height="50"
            preserveAspectRatio="none"
            viewBox="0 0 344 50"
          >
            <path
              d={linePath}
              fill="none"
              className="stroke-white/10"
              strokeWidth="1"
            />
          </svg>
          <motion.div
            style={{
              width: '40px',
              height: '4px',
              borderRadius: '4px',
              position: 'absolute',
              left: 0,
              bottom: 'calc(50% - 4px)',
              background: 'white',
              boxShadow: 'rgba(255, 255, 255, 0.4) 0px 0px 20px 10px',
              zIndex: 10,
              offsetPath: motionPath,
            }}
            initial={{
              offsetDistance: '0%',
            }}
            animate={{
              offsetDistance,
            }}
            transition={{
              duration: 0.6,
              ease: [0.32, 0.72, 0, 1],
            }}
          />
          {TABS.map((tab, index) => {
            const tabDistance = `${motionHorizontalPadding +
              (index / (TABS.length - 1)) * (100 - motionHorizontalPadding * 2)
              }%`;

            return (
              <button
                key={index}
                className="absolute p-5"
                style={{
                  offsetPath: motionPath,
                  offsetDistance: tabDistance,
                  top: '28%',
                }}
                onClick={() => setActiveTab(index)}
              >
                <tab.icon className="h-5 w-5 text-primary-dark-12" />
              </button>
            );
          })}
          <div
            className="pointer-events-none relative z-10 overflow-hidden"
            style={{
              filter: 'drop-shadow(rgba(0,0,0, 0.5) 0px 3px 10px)',
            }}
          >
            <svg
              xmlns="http://www.w3.org/2000/svg"
              width="344"
              height="50"
              preserveAspectRatio="none"
              viewBox="0 0 344 50"
            >
              <path
                d="M 0 40 Q 172 10 344 40 V 68 L 0 68 L 0 40"
                className="fill-background"
              />
            </svg>
          </div>
        </div>
      </div>
    </div>
  );
}