Interactive Spotify Pills

August 2026

Dynamic Border-Radius Morphing Filter Group

Inspired by Spotify's library navigation, this component calculates the active state of neighboring pills to smoothly morph border radii between alone, left-anchored, and center segments without border doubling.

spotify-pills.tsx

import { motion, MotionProps, Variants } from "motion/react";
import { cn } from "@/lib/utils";
import { useCallback, useState } from "react";

const variants: Variants = {
  alone: {
    borderRadius: "9999px",
    marginLeft: "12px",
    paddingLeft: "8px",
  },
  left: {
    borderTopLeftRadius: "0px",
    borderTopRightRadius: "9999px",
    borderBottomLeftRadius: "0px",
    borderBottomRightRadius: "9999px",
    marginLeft: "-14px",
    paddingLeft: "16px",
  },
  center: {
    borderTopLeftRadius: "0px",
    borderTopRightRadius: "0px",
    borderBottomLeftRadius: "0px",
    borderBottomRightRadius: "0px",
    borderRightColor: "transparent",
    borderLeftColor: "transparent",
    marginLeft: "-14px",
    paddingLeft: "16px",
  },
};

type FilterItemProps = {
  children: React.ReactNode;
  active?: boolean;
  isStyle: "left" | "center" | "alone";
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "type"> &
  MotionProps;

function FilterItem({
  children,
  active,
  isStyle,
  className,
  ...props
}: FilterItemProps) {
  return (
    <motion.button
      className={cn(
        "ml-3 inline-flex h-8 items-center justify-center",
        "rounded-full border border-neutral-300 px-2 text-sm transition-colors",
        "text-neutral-600 dark:border-neutral-800 dark:text-neutral-200",
        active
          ? "bg-neutral-100 dark:bg-[#171717]"
          : "bg-white text-neutral-400! hover:bg-neutral-50 dark:bg-black dark:text-neutral-500! dark:hover:bg-neutral-900",
        active &&
          isStyle === "alone" &&
          "bg-opacity-[.97] bg-neutral-100 text-neutral-600 dark:bg-[#171717] dark:text-neutral-200",
        isStyle === "alone" && "border-neutral-300! dark:border-neutral-800!",
        className,
      )}
      variants={variants}
      initial={false}
      animate={isStyle}
      transition={{ type: "tween", duration: 0.15 }}
      {...props}
    >
      {children}
    </motion.button>
  );
}

export const DynamicFilters = () => {
  const [filters, setFilters] = useState<Record<string, boolean>>({
    Playlists: false,
    Albums: false,
    Podcasts: false,
    Artists: false,
    Downloaded: false,
  });

  const handleFilter = (filterKey: string) => {
    setFilters((prev) => ({
      ...prev,
      [filterKey]: !prev[filterKey],
    }));
  };

  const getStyle = useCallback(
    (filter: string): "alone" | "left" | "center" => {
      const keys = Object.keys(filters);
      const filterIndex = keys.indexOf(filter);

      if (!filters[filter]) return "alone";

      const prevFilter = filters[keys[filterIndex - 1]];
      const nextFilter = filters[keys[filterIndex + 1]];

      if (prevFilter && nextFilter) return "center";
      if (prevFilter) return "left";

      return "alone";
    },
    [filters],
  );

  return (
    <div className="flex">
      {Object.entries(filters).map(([filter, active], index) => (
        <FilterItem
          key={filter}
          active={active}
          onClick={() => handleFilter(filter)}
          className="last:hidden sm:last:flex"
          isStyle={getStyle(filter)}
          style={{ zIndex: Object.keys(filters).length - index }}
        >
          {filter}
        </FilterItem>
      ))}
    </div>
  );
};

More Soon