Filter Interaction

list-item.tsx
"use client";
import { motion, MotionConfig } from "motion/react";
import { Dispatch, SetStateAction, useState } from "react";
import clsx from "clsx";

import {
  Appointment01Icon,
  BalloonsIcon,
  GoogleMapsIcon,
  ZoomIcon,
  ReminderIcon,
  TaskDaily01Icon,
  Tick02Icon,
  FilterHorizontalIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsFreeIcons } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";

export type FilterKey = (typeof filterKeys)[number];

// Change Here
export const filterKeys = [
  {
    name: "tasks",
    Icon: ({ size }: { size: number }) => (
      <HugeiconsIcon icon={TaskDaily01Icon} size={size} />
    ),
  },
  {
    name: "events",
    Icon: ({ size }: { size: number }) => (
      <HugeiconsIcon icon={GoogleMapsIcon} size={size} />
    ),
  },
  {
    name: "reminders",
    Icon: ({ size }: { size: number }) => (
      <HugeiconsIcon icon={ReminderIcon} size={size} />
    ),
  },
  {
    name: "appointments",
    Icon: ({ size }: { size: number }) => (
      <HugeiconsIcon icon={Appointment01Icon} size={size} />
    ),
  },
  {
    name: "meetings",
    Icon: ({ size }: { size: number }) => (
      <HugeiconsIcon icon={ZoomIcon} size={size} />
    ),
  },
  {
    name: "celebrations",
    Icon: ({ size }: { size: number }) => (
      <HugeiconsIcon icon={BalloonsIcon} size={size} />
    ),
  },
];

function ListItem(props: {
  index: number;
  filterKey: FilterKey;
  selectedFilterKey: FilterKey;
  setSelectedFilterKey: Dispatch<SetStateAction<FilterKey>>;
  setIsOpened: Dispatch<SetStateAction<boolean>>;
}) {
  const {
    index,
    filterKey,
    selectedFilterKey,
    setSelectedFilterKey,
    setIsOpened,
  } = props;
  const delay = (index + 8) * 0.025;

  return (
    <motion.div
      initial={{ opacity: 0, y: 40 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{
        type: "spring",
        bounce: 0.1,
        duration: 0.25,
        delay,
        ease: [0.215, 0.61, 0.355, 1],
      }}
      onClick={() => {
        setSelectedFilterKey(filterKey);

        setTimeout(() => {
          setIsOpened(false);
        }, 150);
      }}
      className="px-3 py-2 rounded-2xl flex justify-between items-center cursor-default hover:bg-accent  text-foreground"
    >
      <div className="flex items-center gap-x-3">
        <span className="text-muted-foreground">
          <filterKey.Icon size={24} />
        </span>
        <span className="capitalize">{filterKey.name}</span>
      </div>
      <div
        className={clsx(
          "relative border-border w-6 h-6 overflow-hidden rounded-full",
          selectedFilterKey.name == filterKey.name
            ? "border-none"
            : "border-[2px]"
        )}
      >
        {selectedFilterKey.name == filterKey.name && (
          <div className="absolute inset-0 bg-primary flex justify-center items-center text-primary-foreground">
            <HugeiconsIcon icon={Tick02Icon} size={16} />
          </div>
        )}
      </div>
    </motion.div>
  );
}

const FilterInteraction = () => {
  const [selectedFilterKey, setSelectedFilterKey] = useState(filterKeys[0]);
  const [isOpened, setIsOpened] = useState(false);

  return (
    <section className="flex justify-center items-center fill-muted-foreground/70">
      <MotionConfig
        transition={{ type: "spring", duration: 0.85, bounce: 0.35 }}
      >
        <div
          onClick={() => setIsOpened(true)}
          className="relative left-2.5 w-20 h-20 flex justify-center items-center"
        >
          <HugeiconsIcon
            icon={FilterHorizontalIcon}
            className="text-foreground relative z-10 fill-none"
            size={36}
          />
          <motion.div
            layoutId="wrapper"
            className="absolute inset-0 z-[2] bg-background border-border"
            style={{ borderRadius: 40, borderWidth: 1 }}
          />
        </div>
        <motion.div
          initial={{ x: 0 }}
          animate={{
            x: isOpened ? -20 : 0,
            // transition: { delay: isOpened ? 0 : 0.2 },
          }}
          transition={{ type: "spring", bounce: 0.3, duration: 1.5 }}
          className="relative right-2.5 w-20 h-20 border border-border rounded-full flex justify-center items-center bg-background"
        >
          <span className="text-muted-foreground">
            <selectedFilterKey.Icon size={36} />
          </span>
        </motion.div>

        {isOpened && (
          <motion.section
            layoutId="wrapper"
            className="absolute z-20 w-72 px-1 py-1 bg-card border border-border text-xl overflow-hidden "
            style={{ borderRadius: 20, borderWidth: 1 }}
          >
            <div className="flex flex-col gap-1">
              {filterKeys.map((item, index) => (
                <ListItem
                  key={item.name}
                  index={index}
                  filterKey={item}
                  selectedFilterKey={selectedFilterKey}
                  setSelectedFilterKey={setSelectedFilterKey}
                  setIsOpened={setIsOpened}
                />
              ))}
            </div>
          </motion.section>
        )}
      </MotionConfig>
    </section>
  );
};

export default FilterInteraction;

Installation

npx shadcn@latest add "https://uselayouts.com/r/list-item.json"

Install dependencies

npm install motion clsx @hugeicons/react @hugeicons/core-free-icons

Copy the code

Copy the code from the Code tab above into components/filter-interaction.tsx.

Update imports

Update the imports to match your project structure.

Usage

Customizing Content

You can easily add or remove filter options by modifying the filterKeys array:

// Change Here
export const filterKeys = [
  {
    name: "tasks",
    icon: FolderIcon,
  },
  // ...
];
import FilterInteraction from "@/components/filter-interaction";

export default function Page() {
  return (
    <div className="flex items-center justify-center h-[400px] w-full">
      <FilterInteraction />
    </div>
  );
}

Features

  • Shared layout animation - Morphing container using Framer Motion's layoutId
  • Staggered list items - Each filter option animates in with a calculated delay
  • Selection indicator - Checkmark appears on selected item with primary color
  • Two-button design - Filter button and selected value displayed side by side
  • Spring physics - Natural-feeling animations with configurable bounce and duration
  • Hover states - Subtle accent background on hover for better feedback