View Toggle

PreviousNext

Smoothly rearrange items with multiple animation styles when switching views.

Docs
animbitscomponent

Preview

Loading preview…
registry/new-york/animations/transitions/list-grid.tsx
"use client";

import React, { useState, useId } from "react";
import { motion, AnimatePresence } from "motion/react";
import { cn } from "@/lib/utils";
import { Grid, List } from "lucide-react";

export interface ListGridItem {
    id: string | number;
    title: string;
    subtitle: string;
    status: string;
    avatar?: string;
}

interface ListGridProps {
    items: ListGridItem[];
    className?: string;
    views?: Array<"grid" | "list" | "compact" | "card">;
    variant?: "smooth" | "stagger" | "scale-fade" | "crossfade";
}

export function ListGrid({ items, className, views = ["grid", "list"], variant = "smooth" }: ListGridProps) {
    const [view, setView] = useState<"grid" | "list" | "compact" | "card">(views[0]);
    const id = useId();

    const getItemTransition = (index: number) => {
        switch (variant) {
            case "smooth":
                return { type: "spring" as const, bounce: 0.2, duration: 0.6 };
            case "stagger":
                return { type: "spring" as const, bounce: 0.2, duration: 0.6, delay: index * 0.05 };
            case "scale-fade":
                return { type: "spring" as const, bounce: 0.15, duration: 0.5 };
            case "crossfade":
                return { type: "tween" as const, duration: 0.3 };
            default:
                return { type: "spring" as const, bounce: 0.2, duration: 0.6 };
        }
    };

    const getItemInitial = () => {
        switch (variant) {
            case "scale-fade":
                return { opacity: 0, scale: 0.9 };
            case "crossfade":
                return { opacity: 0 };
            default:
                return { opacity: 0, scale: 0.9 };
        }
    };

    const getItemAnimate = () => {
        switch (variant) {
            case "scale-fade":
                return { opacity: 1, scale: 1 };
            case "crossfade":
                return { opacity: 1 };
            default:
                return { opacity: 1, scale: 1 };
        }
    };

    return (
        <div className={cn("flex flex-col items-center justify-center p-8 bg-zinc-50 dark:bg-zinc-900 rounded-xl min-h-[400px] w-full", className)}>
            <div className="w-full max-w-md">
                <div className="flex justify-between items-center mb-6">
                    <h3 className="text-sm font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
                        Items
                    </h3>
                    <div className="flex bg-white dark:bg-zinc-800 rounded-lg p-1 border border-zinc-200 dark:border-zinc-700">
                        <button
                            onClick={() => setView("grid")}
                            className={cn(
                                "p-2 rounded-md transition-colors relative",
                                view === "grid"
                                    ? "text-zinc-900 dark:text-zinc-100"
                                    : "text-zinc-400 hover:text-zinc-600"
                            )}
                        >
                            {view === "grid" && (
                                <motion.div
                                    layoutId={`${id}-bg`}
                                    className="absolute inset-0 bg-zinc-100 dark:bg-zinc-700 rounded-md -z-10 shadow-sm"
                                    transition={{ duration: 0.2 }}
                                />
                            )}
                            <Grid size={16} />
                        </button>
                        <button
                            onClick={() => setView("list")}
                            className={cn(
                                "p-2 rounded-md transition-colors relative",
                                view === "list"
                                    ? "text-zinc-900 dark:text-zinc-100"
                                    : "text-zinc-400 hover:text-zinc-600"
                            )}
                        >
                            {view === "list" && (
                                <motion.div
                                    layoutId={`${id}-bg`}
                                    className="absolute inset-0 bg-zinc-100 dark:bg-zinc-700 rounded-md -z-10 shadow-sm"
                                    transition={{ duration: 0.2 }}
                                />
                            )}
                            <List size={16} />
                        </button>
                    </div>
                </div>

                <motion.div
                    layout
                    className={cn(
                        "grid gap-4",
                        view === "grid" ? "grid-cols-2" : "grid-cols-1"
                    )}
                >
                    <AnimatePresence>
                        {items.map((item, index) => (
                            <motion.div
                                key={item.id}
                                layout
                                initial={getItemInitial()}
                                animate={getItemAnimate()}
                                exit={getItemInitial()}
                                transition={getItemTransition(index)}
                                className={cn(
                                    "bg-white dark:bg-zinc-800 p-4 rounded-xl border border-zinc-200 dark:border-zinc-700 shadow-sm hover:shadow-md transition-shadow cursor-default",
                                    view === "list" && "flex items-center justify-between"
                                )}
                            >
                                <div className="flex items-center gap-3">
                                    {item.avatar ? (
                                        <img
                                            src={item.avatar}
                                            alt={item.title}
                                            className="w-10 h-10 rounded-full object-cover"
                                        />
                                    ) : (
                                        <div className="w-10 h-10 rounded-full bg-gradient-to-br from-zinc-100 to-zinc-200 dark:from-zinc-700 dark:to-zinc-800 flex items-center justify-center text-xs font-bold text-zinc-500">
                                            {item.title.substring(0, 2).toUpperCase()}
                                        </div>
                                    )}
                                    <div>
                                        <h4 className="font-semibold text-zinc-900 dark:text-zinc-100 text-sm">
                                            {item.title}
                                        </h4>
                                        <p className="text-xs text-zinc-500 dark:text-zinc-400">
                                            {item.subtitle}
                                        </p>
                                    </div>
                                </div>

                                {view === "list" && (
                                    <span
                                        className={cn(
                                            "px-2 py-1 rounded-full text-xs font-medium",
                                            item.status === "Active" &&
                                            "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400",
                                            item.status === "Pending" &&
                                            "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
                                            item.status === "Archived" &&
                                            "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-400"
                                        )}
                                    >
                                        {item.status}
                                    </span>
                                )}

                                {view === "grid" && (
                                    <div className="mt-4 flex justify-between items-center">
                                        <span
                                            className={cn(
                                                "px-2 py-1 rounded-full text-xs font-medium",
                                                item.status === "Active" &&
                                                "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400",
                                                item.status === "Pending" &&
                                                "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
                                                item.status === "Archived" &&
                                                "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-400"
                                            )}
                                        >
                                            {item.status}
                                        </span>
                                    </div>
                                )}
                            </motion.div>
                        ))}
                    </AnimatePresence>
                </motion.div>
            </div>
        </div>
    );
}

Installation

npx shadcn@latest add @animbits/transitions-view-toggle

Usage

import { TransitionsViewToggle } from "@/components/transitions-view-toggle"
<TransitionsViewToggle />