Compare Drawer
Installation
Add the following Soul components
The compare-drawer component uses the button-link and toaster components. Make sure you have added them to your project.
Install the following dependencies
npm install @radix-ui/react-portal lucide-react nuqs
Copy and paste the following code into your project
primitives/compare-drawer/index.tsx
'use client';import * as Portal from '@radix-ui/react-portal';import { ArrowRight, X } from 'lucide-react';import Image from 'next/image';import Link from 'next/link';import { useQueryState } from 'nuqs';import { createContext, ReactNode, startTransition, useContext, useEffect, useOptimistic,} from 'react';import { ButtonLink } from '@/vibes/soul/primitives/button-link';import { toast } from '@/vibes/soul/primitives/toaster';import { compareParser } from './loader';interface OptimisticAction { type: 'add' | 'remove'; item: CompareDrawerItem;}interface CompareDrawerContext { optimisticItems: CompareDrawerItem[]; setOptimisticItems: (action: OptimisticAction) => void; maxItems?: number;}export const CompareDrawerContext = createContext<CompareDrawerContext | undefined>(undefined);export interface CompareDrawerProviderProps { children: ReactNode; items: CompareDrawerItem[]; maxItems?: number; maxCompareLimitMessage?: string;}export function CompareDrawerProvider({ children, items, maxItems = 12, maxCompareLimitMessage = "You've reached the maximum number of products for comparison. Remove a product to add a new one.",}: CompareDrawerProviderProps) { useEffect(() => { if (items.length >= maxItems) { toast.warning(maxCompareLimitMessage); } }, [items.length, maxItems, maxCompareLimitMessage]); const [optimisticItems, setOptimisticItems] = useOptimistic( items, (state: CompareDrawerItem[], { type, item }: OptimisticAction) => { switch (type) { case 'add': return [...state, item].sort((a, b) => { const numA = Number(a.id); const numB = Number(b.id); if (!Number.isNaN(numA) && !Number.isNaN(numB)) { return numA - numB; } if (!Number.isNaN(numA)) return -1; if (!Number.isNaN(numB)) return 1; return a.id < b.id ? -1 : 1; }); case 'remove': return state.filter((i) => i.id !== item.id); default: return state; } }, ); return ( <CompareDrawerContext value={{ optimisticItems, setOptimisticItems, maxItems }}> {children} </CompareDrawerContext> );}export function useCompareDrawer() { const context = useContext(CompareDrawerContext); if (context === undefined) { throw new Error('useCompareDrawer must be used within a CompareDrawerProvider'); } return context;}function getInitials(name: string): string { return name .split(' ') .map((word) => word[0]) .join('') .toUpperCase() .slice(0, 2);}interface CompareDrawerItem { id: string; image?: { src: string; alt: string }; href: string; title: string;}export interface CompareDrawerProps { href?: string; paramName?: string; submitLabel?: string; removeLabel?: string;}/** * This component supports various CSS variables for theming. Here's a comprehensive list, along * with their default values: * * ```css * :root { * --compare-drawer-background: hsl(var(--background)); * --compare-drawer-font-family: var(--font-family-body); * --compare-drawer-card-focus: hsl(var(--primary)); * --compare-drawer-card-border: hsl(var(--contrast-100)); * --compare-drawer-card-background: hsl(var(--background)); * --compare-drawer-card-background-hover: hsl(var(--contrast-100)); * --compare-drawer-card-image-background: hsl(var(--contrast-100)); * --compare-drawer-empty-image-text: hsl(var(--primary-shadow)); * --compare-drawer-card-text: hsl(var(--foreground)); * --compare-drawer-dismiss-border: hsl(var(--contast-100)); * --compare-drawer-dismiss-border-hover: hsl(var(--contast-200)); * --compare-drawer-dismiss-background: hsl(var(--background)); * --compare-drawer-dismiss-background-hover: hsl(var(--contrast-100)); * --compare-drawer-dismiss-icon: hsl(var(--contrast-400)); * --compare-drawer-dismiss-icon-hover: hsl(var(--foreground)); * } * ``` */export function CompareDrawer({ href = '/compare', paramName = 'compare', submitLabel = 'Compare', removeLabel = 'Remove',}: CompareDrawerProps) { const [params, setParam] = useQueryState(paramName, compareParser); const { optimisticItems, setOptimisticItems } = useCompareDrawer(); return ( optimisticItems.length > 0 && ( <Portal.Root asChild> <div className="sticky bottom-0 z-10 w-full border-t bg-[var(--compare-drawer-background,hsl(var(--background)))] px-3 py-4 @container @md:py-5 @xl:px-6 @5xl:px-10"> <div className="mx-auto flex w-full max-w-7xl flex-col items-start justify-end gap-x-3 gap-y-4 @md:flex-row"> <div className="flex flex-1 flex-wrap justify-end gap-4"> {optimisticItems.map((item) => ( <div className="relative" key={item.id}> <Link className="group relative flex max-w-56 items-center overflow-hidden whitespace-nowrap rounded-xl border border-[var(--compare-drawer-link-border,hsl(var(--contrast-100)))] bg-[var(--compare-drawer-card-background,hsl(var(--background)))] font-semibold ring-[var(--compare-drawer-card-focus,hsl(var(--primary)))] transition-all duration-150 hover:bg-[var(--compare-drawer-card-background-hover,hsl(var(--contrast-100)))] focus:outline-none focus:ring-2" href={item.href} > <div className="relative aspect-square w-12 shrink-0 bg-[var(--compare-drawer-card-image-background,hsl(var(--contrast-100)))]"> {item.image?.src != null ? ( <Image alt={item.image.alt} className="rounded-lg object-cover @4xl:rounded-r-none" fill sizes="3rem" src={item.image.src} /> ) : ( <span className="max-w-full break-all p-1 text-xs text-[var(--compare-drawer-empty-image-text,color-mix(in_oklab,hsl(var(--primary)),black_75%))] opacity-20"> {getInitials(item.title)} </span> )} </div> <span className="hidden truncate pl-3 pr-5 text-[var(--compare-drawer-card-text,hsl(var(--foreground)))] @4xl:block"> {item.title} </span> </Link> <button aria-label={`${removeLabel} ${item.title}`} className="hover:text-[var(--compare-drawer-dismiss-icon-hover,hsl(var(--foreground))] absolute -right-2.5 -top-2.5 flex h-7 w-7 items-center justify-center rounded-full border border-[var(--compare-drawer-dismiss-border,hsl(var(--contrast-100)))] bg-[var(--compare-drawer-dismiss-background,hsl(var(--background)))] text-[var(--compare-drawer-dismiss-icon,hsl(var(--contrast-400)))] transition-colors duration-150 hover:border-[var(--compare-drawer-dismiss-border-hover,hsl(var(--contrast-200)))] hover:bg-[var(--compare-drawer-dismiss-background-hover,hsl(var(--contrast-100)))]" onClick={() => { startTransition(async () => { setOptimisticItems({ type: 'remove', item }); await setParam((prev) => { const next = prev?.filter((v) => v !== item.id) ?? []; return next.length > 0 ? next : null; }); }); }} type="button" > <X absoluteStrokeWidth size={16} strokeWidth={1.5} /> </button> </div> ))} </div> <ButtonLink className="hidden @md:block" href={`${href}?${paramName}=${params?.toString()}`} size="medium" variant="primary" > <span className="inline-flex items-center gap-1"> {submitLabel} <ArrowRight absoluteStrokeWidth size={20} strokeWidth={1} /> </span> </ButtonLink> <ButtonLink className="w-full @md:hidden" href={href} size="small" variant="primary"> <span className="inline-flex items-center gap-1"> {submitLabel} <ArrowRight absoluteStrokeWidth size={16} strokeWidth={1} /> </span> </ButtonLink> </div> </div> </Portal.Root> ) );}
primitives/compare-drawer/loader.ts
import { createLoader, parseAsArrayOf, parseAsString } from 'nuqs/server';export const compareParser = parseAsArrayOf(parseAsString).withOptions({ shallow: false, scroll: false,});export const createCompareLoader = (paramName = 'compare') => createLoader({ [paramName]: compareParser });