Web InnoventixFreeCode

Filter Menu

Original · free

filter dropdown with checkboxes

byWeb InnoventixReact + Tailwind
menufiltermenus
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/menu-filter.json
menu-filter.tsx
"use client";

import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type FilterOption = { id: string; label: string };
type FilterGroup = { id: string; label: string; options: FilterOption[] };

const GROUPS: FilterGroup[] = [
  {
    id: "roast",
    label: "Roast level",
    options: [
      { id: "roast:light", label: "Light" },
      { id: "roast:medium", label: "Medium" },
      { id: "roast:medium-dark", label: "Medium-dark" },
      { id: "roast:dark", label: "Dark" },
    ],
  },
  {
    id: "origin",
    label: "Origin",
    options: [
      { id: "origin:ethiopia", label: "Ethiopia" },
      { id: "origin:colombia", label: "Colombia" },
      { id: "origin:guatemala", label: "Guatemala" },
      { id: "origin:kenya", label: "Kenya" },
      { id: "origin:brazil", label: "Brazil" },
      { id: "origin:sumatra", label: "Sumatra" },
    ],
  },
  {
    id: "process",
    label: "Process",
    options: [
      { id: "process:washed", label: "Washed" },
      { id: "process:natural", label: "Natural" },
      { id: "process:honey", label: "Honey" },
      { id: "process:anaerobic", label: "Anaerobic" },
    ],
  },
  {
    id: "notes",
    label: "Tasting notes",
    options: [
      { id: "notes:fruity", label: "Fruity" },
      { id: "notes:chocolate", label: "Chocolate" },
      { id: "notes:nutty", label: "Nutty" },
      { id: "notes:floral", label: "Floral" },
      { id: "notes:citrus", label: "Citrus" },
      { id: "notes:caramel", label: "Caramel" },
    ],
  },
];

type RawProduct = {
  id: string;
  name: string;
  roaster: string;
  price: number;
  roast: string;
  origin: string;
  process: string;
  notes: string[];
};

const RAW_PRODUCTS: RawProduct[] = [
  { id: "reko", name: "Yirgacheffe Reko", roaster: "Ridgeline Coffee Roasters", price: 19, roast: "roast:light", origin: "origin:ethiopia", process: "process:washed", notes: ["notes:floral", "notes:citrus", "notes:fruity"] },
  { id: "guji", name: "Guji Natural G1", roaster: "Ridgeline Coffee Roasters", price: 21, roast: "roast:light", origin: "origin:ethiopia", process: "process:natural", notes: ["notes:fruity", "notes:floral"] },
  { id: "huila", name: "Huila Reserva", roaster: "Cormorant Coffee", price: 17, roast: "roast:medium", origin: "origin:colombia", process: "process:washed", notes: ["notes:caramel", "notes:chocolate"] },
  { id: "narino", name: "Nariño Sunrise", roaster: "Cormorant Coffee", price: 18, roast: "roast:medium", origin: "origin:colombia", process: "process:honey", notes: ["notes:caramel", "notes:fruity"] },
  { id: "antigua", name: "Antigua Volcán", roaster: "Foundry Roasters", price: 16, roast: "roast:medium-dark", origin: "origin:guatemala", process: "process:washed", notes: ["notes:chocolate", "notes:nutty"] },
  { id: "nyeri", name: "Nyeri AA Peaberry", roaster: "Foundry Roasters", price: 22, roast: "roast:light", origin: "origin:kenya", process: "process:washed", notes: ["notes:citrus", "notes:fruity"] },
  { id: "cerrado", name: "Cerrado Gold", roaster: "Meridian Coffee Co.", price: 15, roast: "roast:dark", origin: "origin:brazil", process: "process:natural", notes: ["notes:nutty", "notes:chocolate", "notes:caramel"] },
  { id: "mandheling", name: "Mandheling Reserve", roaster: "Meridian Coffee Co.", price: 16, roast: "roast:dark", origin: "origin:sumatra", process: "process:natural", notes: ["notes:chocolate", "notes:nutty"] },
  { id: "aurora", name: "Finca La Aurora", roaster: "Cormorant Coffee", price: 24, roast: "roast:medium", origin: "origin:guatemala", process: "process:anaerobic", notes: ["notes:fruity", "notes:floral", "notes:citrus"] },
];

type Product = RawProduct & { attrs: string[]; set: Set<string> };

const PRODUCTS: Product[] = RAW_PRODUCTS.map((p) => {
  const attrs = [p.roast, p.origin, p.process, ...p.notes];
  return { ...p, attrs, set: new Set(attrs) };
});

const OPTION_LABEL: Record<string, string> = {};
const OPTION_COUNT: Record<string, number> = {};
for (const group of GROUPS) {
  for (const option of group.options) {
    OPTION_LABEL[option.id] = option.label;
    OPTION_COUNT[option.id] = PRODUCTS.filter((p) => p.set.has(option.id)).length;
  }
}

const KEYFRAMES = `
@keyframes mf-badge-pop { 0% { transform: scale(0.5); opacity: 0; } 60% { transform: scale(1.15); } 100% { transform: scale(1); opacity: 1; } }
@keyframes mf-chip-in { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: translateY(0); } }
@keyframes mf-card-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.mf-badge { animation: mf-badge-pop 0.3s cubic-bezier(0.16, 1, 0.3, 1); }
.mf-chip { animation: mf-chip-in 0.22s ease-out; }
.mf-card { animation: mf-card-in 0.32s cubic-bezier(0.16, 1, 0.3, 1) both; }
@media (prefers-reduced-motion: reduce) {
  .mf-badge, .mf-chip, .mf-card { animation: none !important; }
}
`;

function FunnelIcon() {
  return (
    <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-4 w-4">
      <path d="M3 4.5h14L11.5 11v4.5L8.5 17v-6L3 4.5Z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
    </svg>
  );
}

function ChevronIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
      <path d="M5.5 8l4.5 4.5L14.5 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function CheckIcon() {
  return (
    <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="h-3.5 w-3.5">
      <path d="M3.5 8.5l3 3 6-7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function SearchIcon() {
  return (
    <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-4 w-4">
      <circle cx="9" cy="9" r="5.5" stroke="currentColor" strokeWidth="1.6" />
      <path d="M13.5 13.5L17 17" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
    </svg>
  );
}

function CloseIcon() {
  return (
    <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="h-3 w-3">
      <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
    </svg>
  );
}

export default function MenuFilter() {
  const reduce = useReducedMotion();
  const [open, setOpen] = useState(false);
  const [selected, setSelected] = useState<Set<string>>(new Set());
  const [query, setQuery] = useState("");
  const [activeItem, setActiveItem] = useState<string | null>(null);

  const triggerRef = useRef<HTMLButtonElement>(null);
  const panelRef = useRef<HTMLDivElement>(null);
  const searchRef = useRef<HTMLInputElement>(null);
  const clearRef = useRef<HTMLButtonElement>(null);
  const applyRef = useRef<HTMLButtonElement>(null);
  const itemRefs = useRef<Map<string, HTMLButtonElement>>(new Map());

  const reactId = useId();
  const panelId = `mf-panel-${reactId}`;
  const searchId = `mf-search-${reactId}`;
  const resultsId = `mf-results-${reactId}`;

  const visibleGroups = useMemo(() => {
    const q = query.trim().toLowerCase();
    if (!q) return GROUPS;
    return GROUPS.map((g) => ({
      ...g,
      options: g.options.filter((o) => o.label.toLowerCase().includes(q)),
    })).filter((g) => g.options.length > 0);
  }, [query]);

  const visibleItemIds = useMemo(
    () => visibleGroups.flatMap((g) => g.options.map((o) => o.id)),
    [visibleGroups],
  );

  const selectedIds = useMemo(
    () => GROUPS.flatMap((g) => g.options.filter((o) => selected.has(o.id)).map((o) => o.id)),
    [selected],
  );

  const filtered = useMemo(() => {
    const selectedByGroup = GROUPS.map((g) => g.options.filter((o) => selected.has(o.id)).map((o) => o.id));
    return PRODUCTS.filter((p) =>
      selectedByGroup.every((sel) => sel.length === 0 || sel.some((id) => p.set.has(id))),
    );
  }, [selected]);

  const count = selected.size;
  const resultCount = filtered.length;

  const focusItem = useCallback((id: string | null) => {
    if (!id) {
      searchRef.current?.focus();
      return;
    }
    setActiveItem(id);
    itemRefs.current.get(id)?.focus();
  }, []);

  const toggle = useCallback((id: string) => {
    setSelected((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  }, []);

  const clearAll = useCallback(() => setSelected(new Set()), []);

  const close = useCallback(() => {
    setOpen(false);
    triggerRef.current?.focus();
  }, []);

  useEffect(() => {
    if (!open) return;
    const raf = requestAnimationFrame(() => searchRef.current?.focus());
    return () => cancelAnimationFrame(raf);
  }, [open]);

  useEffect(() => {
    if (open && (activeItem === null || !visibleItemIds.includes(activeItem))) {
      setActiveItem(visibleItemIds[0] ?? null);
    }
  }, [open, visibleItemIds, activeItem]);

  useEffect(() => {
    if (!open) return;
    const onDown = (e: MouseEvent) => {
      const target = e.target as Node | null;
      if (!target) return;
      if (panelRef.current?.contains(target) || triggerRef.current?.contains(target)) return;
      setOpen(false);
    };
    document.addEventListener("mousedown", onDown);
    return () => document.removeEventListener("mousedown", onDown);
  }, [open]);

  const getFocusables = useCallback((): HTMLElement[] => {
    const panel = panelRef.current;
    if (!panel) return [];
    const nodes = panel.querySelectorAll<HTMLElement>("button, input, [tabindex]");
    return Array.from(nodes).filter(
      (el) => el.tabIndex !== -1 && !(el as HTMLButtonElement).disabled && el.offsetParent !== null,
    );
  }, []);

  const onPanelKeyDown = useCallback(
    (e: ReactKeyboardEvent<HTMLDivElement>) => {
      if (e.key === "Escape") {
        e.preventDefault();
        close();
        return;
      }
      if (e.key === "Tab") {
        const focusables = getFocusables();
        if (focusables.length === 0) return;
        const first = focusables[0];
        const last = focusables[focusables.length - 1];
        if (e.shiftKey && document.activeElement === first) {
          e.preventDefault();
          last.focus();
        } else if (!e.shiftKey && document.activeElement === last) {
          e.preventDefault();
          first.focus();
        }
      }
    },
    [close, getFocusables],
  );

  const onSearchKeyDown = useCallback(
    (e: ReactKeyboardEvent<HTMLInputElement>) => {
      if (e.key === "ArrowDown") {
        e.preventDefault();
        focusItem(visibleItemIds[0] ?? null);
      }
    },
    [focusItem, visibleItemIds],
  );

  const onItemKeyDown = useCallback(
    (e: ReactKeyboardEvent<HTMLButtonElement>, id: string) => {
      const idx = visibleItemIds.indexOf(id);
      switch (e.key) {
        case "ArrowDown":
          e.preventDefault();
          if (idx < visibleItemIds.length - 1) focusItem(visibleItemIds[idx + 1]);
          break;
        case "ArrowUp":
          e.preventDefault();
          if (idx > 0) focusItem(visibleItemIds[idx - 1]);
          else focusItem(null);
          break;
        case "Home":
          e.preventDefault();
          focusItem(visibleItemIds[0] ?? null);
          break;
        case "End":
          e.preventDefault();
          focusItem(visibleItemIds[visibleItemIds.length - 1] ?? null);
          break;
        case " ":
        case "Enter":
          e.preventDefault();
          toggle(id);
          break;
      }
    },
    [focusItem, toggle, visibleItemIds],
  );

  return (
    <section className="relative w-full overflow-hidden bg-neutral-50 px-4 py-16 text-neutral-900 dark:bg-neutral-950 dark:text-neutral-100 sm:py-24">
      <style>{KEYFRAMES}</style>
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -top-24 left-1/2 h-72 w-[38rem] max-w-[90vw] -translate-x-1/2 rounded-full bg-gradient-to-tr from-indigo-200/50 via-violet-200/40 to-transparent blur-3xl dark:from-indigo-500/15 dark:via-violet-500/10"
      />

      <div className="relative mx-auto w-full max-w-5xl">
        <header className="mb-8">
          <p className="mb-2 text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
            The Roastery
          </p>
          <h2 className="text-3xl font-semibold tracking-tight sm:text-4xl">Single-origin coffee</h2>
          <p className="mt-2 max-w-xl text-sm text-neutral-600 dark:text-neutral-400">
            Small-lot beans roasted to order. Narrow the shelf by roast, origin, process, and the flavors you chase in the cup.
          </p>
        </header>

        <div className="flex flex-wrap items-center gap-3">
          <div className="relative">
            <button
              ref={triggerRef}
              type="button"
              aria-haspopup="dialog"
              aria-expanded={open}
              aria-controls={panelId}
              onClick={() => (open ? close() : setOpen(true))}
              className="inline-flex items-center gap-2 rounded-xl border border-neutral-300 bg-white px-4 py-2.5 text-sm font-medium text-neutral-800 shadow-sm transition-colors hover:border-neutral-400 hover:bg-neutral-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:border-neutral-600 dark:hover:bg-neutral-800 dark:focus-visible:ring-offset-neutral-950"
            >
              <FunnelIcon />
              <span>Filters</span>
              {count > 0 && (
                <span
                  key={count}
                  className="mf-badge inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full bg-indigo-600 px-1.5 text-xs font-semibold text-white dark:bg-indigo-500"
                >
                  {count}
                </span>
              )}
              <ChevronIcon
                className={`h-4 w-4 text-neutral-400 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
              />
            </button>

            <AnimatePresence>
              {open && (
                <motion.div
                  ref={panelRef}
                  id={panelId}
                  role="dialog"
                  aria-modal="false"
                  aria-label="Filter coffees"
                  onKeyDown={onPanelKeyDown}
                  initial={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97 }}
                  animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
                  exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.98 }}
                  transition={{ duration: reduce ? 0.12 : 0.18, ease: "easeOut" }}
                  style={{ transformOrigin: "top left" }}
                  className="absolute left-0 z-30 mt-2 w-[min(92vw,22rem)] overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-2xl shadow-neutral-900/10 dark:border-neutral-800 dark:bg-neutral-900 dark:shadow-black/40"
                >
                  <div className="border-b border-neutral-200 p-2.5 dark:border-neutral-800">
                    <div className="relative">
                      <span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400">
                        <SearchIcon />
                      </span>
                      <input
                        ref={searchRef}
                        id={searchId}
                        type="text"
                        role="searchbox"
                        aria-label="Search filters"
                        autoComplete="off"
                        value={query}
                        onChange={(e) => setQuery(e.target.value)}
                        onKeyDown={onSearchKeyDown}
                        placeholder="Search filters..."
                        className="w-full rounded-lg border border-neutral-200 bg-neutral-50 py-2 pl-9 pr-3 text-sm text-neutral-900 placeholder:text-neutral-400 focus-visible:border-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/40 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-100 dark:placeholder:text-neutral-500"
                      />
                    </div>
                  </div>

                  <div role="menu" aria-label="Filter options" className="max-h-[18rem] overflow-y-auto p-1.5">
                    {visibleGroups.length === 0 ? (
                      <p className="px-3 py-8 text-center text-sm text-neutral-500 dark:text-neutral-400">
                        No filters match &ldquo;{query.trim()}&rdquo;
                      </p>
                    ) : (
                      visibleGroups.map((group) => {
                        const headingId = `mf-grp-${reactId}-${group.id}`;
                        return (
                          <div key={group.id} role="group" aria-labelledby={headingId} className="px-1 py-1.5">
                            <div
                              id={headingId}
                              className="px-2 pb-1 text-[0.7rem] font-semibold uppercase tracking-wider text-neutral-400 dark:text-neutral-500"
                            >
                              {group.label}
                            </div>
                            {group.options.map((option) => {
                              const checked = selected.has(option.id);
                              return (
                                <button
                                  key={option.id}
                                  ref={(el) => {
                                    if (el) itemRefs.current.set(option.id, el);
                                    else itemRefs.current.delete(option.id);
                                  }}
                                  type="button"
                                  role="menuitemcheckbox"
                                  aria-checked={checked}
                                  tabIndex={activeItem === option.id ? 0 : -1}
                                  onClick={() => toggle(option.id)}
                                  onKeyDown={(e) => onItemKeyDown(e, option.id)}
                                  className="group flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm text-neutral-700 transition-colors hover:bg-neutral-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 dark:text-neutral-200 dark:hover:bg-neutral-800"
                                >
                                  <span
                                    aria-hidden="true"
                                    className={`flex h-[1.15rem] w-[1.15rem] flex-none items-center justify-center rounded-md border transition-colors ${
                                      checked
                                        ? "border-indigo-600 bg-indigo-600 text-white dark:border-indigo-500 dark:bg-indigo-500"
                                        : "border-neutral-300 bg-white text-transparent group-hover:border-neutral-400 dark:border-neutral-600 dark:bg-neutral-900"
                                    }`}
                                  >
                                    {checked && <CheckIcon />}
                                  </span>
                                  <span className="flex-1">{option.label}</span>
                                  <span
                                    aria-hidden="true"
                                    className="text-xs tabular-nums text-neutral-400 dark:text-neutral-500"
                                  >
                                    {OPTION_COUNT[option.id]}
                                  </span>
                                </button>
                              );
                            })}
                          </div>
                        );
                      })
                    )}
                  </div>

                  <div className="flex items-center justify-between gap-2 border-t border-neutral-200 px-3 py-2.5 dark:border-neutral-800">
                    <button
                      ref={clearRef}
                      type="button"
                      onClick={clearAll}
                      disabled={count === 0}
                      className="rounded-lg px-2.5 py-1.5 text-sm font-medium text-neutral-600 transition-colors hover:bg-neutral-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-40 dark:text-neutral-300 dark:hover:bg-neutral-800"
                    >
                      Clear all
                    </button>
                    <button
                      ref={applyRef}
                      type="button"
                      onClick={close}
                      className="rounded-lg bg-indigo-600 px-3.5 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:focus-visible:ring-offset-neutral-900"
                    >
                      Show {resultCount} {resultCount === 1 ? "result" : "results"}
                    </button>
                  </div>
                </motion.div>
              )}
            </AnimatePresence>
          </div>

          {selectedIds.map((id) => (
            <button
              key={id}
              type="button"
              onClick={() => toggle(id)}
              aria-label={`Remove ${OPTION_LABEL[id]} filter`}
              className="mf-chip inline-flex items-center gap-1.5 rounded-full border border-indigo-200 bg-indigo-50 py-1 pl-3 pr-2 text-sm font-medium text-indigo-700 transition-colors hover:bg-indigo-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300 dark:hover:bg-indigo-500/20 dark:focus-visible:ring-offset-neutral-950"
            >
              {OPTION_LABEL[id]}
              <span className="text-indigo-400 dark:text-indigo-500">
                <CloseIcon />
              </span>
            </button>
          ))}

          {count > 0 && (
            <button
              type="button"
              onClick={clearAll}
              className="text-sm font-medium text-neutral-500 underline-offset-4 transition-colors hover:text-neutral-800 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:text-neutral-400 dark:hover:text-neutral-100 dark:focus-visible:ring-offset-neutral-950"
            >
              Clear all
            </button>
          )}
        </div>

        <p id={resultsId} aria-live="polite" className="mt-6 text-sm text-neutral-500 dark:text-neutral-400">
          Showing <span className="font-semibold text-neutral-800 dark:text-neutral-100">{resultCount}</span> of{" "}
          {PRODUCTS.length} roasts
        </p>

        {resultCount === 0 ? (
          <div className="mt-4 rounded-2xl border border-dashed border-neutral-300 bg-white/50 px-6 py-16 text-center dark:border-neutral-700 dark:bg-neutral-900/40">
            <p className="text-sm font-medium text-neutral-700 dark:text-neutral-200">No roasts match every filter.</p>
            <p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
              Try removing a tasting note or widening the roast range.
            </p>
            <button
              type="button"
              onClick={clearAll}
              className="mt-4 inline-flex rounded-lg bg-neutral-900 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-neutral-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200 dark:focus-visible:ring-offset-neutral-950"
            >
              Reset filters
            </button>
          </div>
        ) : (
          <ul className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
            {filtered.map((p, i) => (
              <li
                key={p.id}
                className="mf-card group rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm transition-shadow hover:shadow-md dark:border-neutral-800 dark:bg-neutral-900"
                style={{ animationDelay: reduce ? "0ms" : `${Math.min(i, 8) * 45}ms` }}
              >
                <div className="flex items-start justify-between gap-3">
                  <span className="inline-flex items-center rounded-full bg-amber-100 px-2.5 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-400/15 dark:text-amber-300">
                    {OPTION_LABEL[p.origin]}
                  </span>
                  <span className="text-sm font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
                    ${p.price}
                  </span>
                </div>
                <h3 className="mt-3 text-base font-semibold tracking-tight text-neutral-900 dark:text-neutral-50">
                  {p.name}
                </h3>
                <p className="mt-0.5 text-sm text-neutral-500 dark:text-neutral-400">{p.roaster}</p>
                <div className="mt-3 flex flex-wrap gap-1.5">
                  <span className="inline-flex items-center rounded-md bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300">
                    {OPTION_LABEL[p.roast]} roast
                  </span>
                  <span className="inline-flex items-center rounded-md bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300">
                    {OPTION_LABEL[p.process]}
                  </span>
                </div>
                <p className="mt-3 text-xs text-neutral-500 dark:text-neutral-400">
                  {p.notes.map((n) => OPTION_LABEL[n]).join(" · ")}
                </p>
              </li>
            ))}
          </ul>
        )}
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →