shadimage
grootstudio
blockanimated-layoutsource: ok · hit

Animated Layout

A modern and dynamic layout component featuring fluid animations, smooth transitions, and a responsive grid system.

npx shadcn add "https://grootstudio.vercel.app/r/animated-layout.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationmotion
iconslucide-react

Files1 · source cached 10/14/2026, 2:21:20 PM

registry/radix-nova/animated-layout/animated-layout.tsx
"use client";

import React, { useState } from "react";
import { motion, LayoutGroup, AnimatePresence, type Transition } from "motion/react";
import { List, LayoutGrid, Layers, Star, Check } from "lucide-react";
import { cn } from "@/lib/utils";

export interface CollectionItem {
    id: string;
    title: string;
    subtitle: string;
    badge: string;
    image: string;
    icon: React.ElementType;
}

export type ViewMode = "list" | "card" | "pack";

// ─── Constants (hoisted – zero per-render cost) ──────────────────────────────

const S: Transition = { type: "spring", stiffness: 400, damping: 32, mass: 0.8 };
const S_TAB: Transition = { type: "spring", stiffness: 500, damping: 35, mass: 0.6 };
const S_CHECK: Transition = { type: "spring", stiffness: 500, damping: 30 };
const FADE: Transition = { duration: 0.12, ease: [0.25, 0.1, 0.25, 1] };

const GPU: React.CSSProperties = { willChange: "transform", transform: "translateZ(0)", backfaceVisibility: "hidden" };
const RESET = { rotate: 0, x: 0, y: 0 };
const PACK_X = 22;

const TABS: { mode: ViewMode; icon: React.ElementType }[] = [
    { mode: "list", icon: List },
    { mode: "card", icon: LayoutGrid },
    { mode: "pack", icon: Layers },
];

// ─── View-mode class maps (avoids repeated ternaries in render) ──────────────

const GRID_CLS: Record<ViewMode, string> = {
    list: "flex flex-col gap-2",
    card: "grid grid-cols-2 gap-3",
    pack: "h-64 flex items-center justify-center mt-4",
};

const CARD_CLS: Record<ViewMode, string> = {
    list: "flex-row gap-4 w-full rounded-lg",
    card: "flex-col gap-3 w-full items-start rounded-xl",
    pack: "absolute w-56 h-56 items-center justify-center rounded-xl",
};

const IMG_CLS: Record<ViewMode, string> = {
    list: "w-16 h-16 rounded-lg border border-border",
    card: "w-full aspect-square rounded-lg border border-border shadow-sm",
    pack: "w-full h-full rounded-lg border border-border shadow-lg",
};

const WRAP_CLS: Record<ViewMode, string> = {
    list: "p-2",
    card: "p-3",
    pack: "py-4",
};

// ─── Sub-components ──────────────────────────────────────────────────────────

const ViewTab = React.memo(function ViewTab({ active, onClick, icon: Icon }: {
    active: boolean; onClick: () => void; icon: React.ElementType;
}) {
    return (
        <button
            type="button"
            onClick={onClick}
            className={cn(
                "relative flex items-center px-2.5 py-1.5 rounded-md outline-none transition-colors duration-150",
                active ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground"
            )}
        >
            {active && (
                <motion.span
                    layoutId="animated-layout-active-tab"
                    className="absolute inset-0 rounded-md bg-primary shadow-sm"
                    transition={S_TAB}
                    style={GPU}
                />
            )}
            <Icon size={14} strokeWidth={2} className={cn("relative z-10 transition-transform duration-200", active && "scale-110")} />
        </button>
    );
});

const ItemInfo = React.memo(function ItemInfo({ item, view }: { item: CollectionItem; view: ViewMode }) {
    const Icon = item.icon;
    const [checked, setChecked] = useState(false);
    const isCard = view === "card";

    return (
        <motion.div
            layout
            initial={{ opacity: 0, scale: 0.92, filter: "blur(4px)" }}
            animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
            exit={{ opacity: 0, scale: 0.92, filter: "blur(4px)" }}
            transition={FADE}
            className={cn("flex flex-1 items-center justify-between min-w-0", isCard ? "w-full px-1 pb-1" : "px-0")}
        >
            <div className="flex flex-col gap-0.5 min-w-0">
                <div className="flex items-center justify-start">
                    <h3 className={cn(
                        "font-medium text-[15px] leading-tight truncate transition-colors duration-200",
                        checked ? "text-muted-foreground line-through decoration-muted-foreground/50" : "text-foreground"
                    )}>
                        {item.title}
                    </h3>
                    <span className="flex items-center gap-1 px-1 rounded-sm bg-muted/50 border border-border text-primary text-[10px] font-bold shrink-0 ml-2">
                        <Star size={10} className="text-yellow-500 fill-yellow-500" />
                        {item.badge}
                    </span>
                </div>
                <span className={cn(
                    "text-xs font-medium text-muted-foreground flex items-center gap-1.5 transition-opacity duration-200",
                    checked && "opacity-50"
                )}>
                    <span className="flex items-center justify-center p-[3px] bg-muted/50 border border-border rounded-sm">
                        <Icon size={12} strokeWidth={2} className="text-primary/70 shrink-0" />
                    </span>
                    <span className="truncate">{item.subtitle}</span>
                </span>
            </div>

            <motion.button
                type="button"
                role="checkbox"
                aria-checked={checked}
                onClick={() => setChecked((p) => !p)}
                className={cn(
                    "shrink-0 flex items-center justify-center border transition-all duration-200 outline-none rounded-sm",
                    isCard ? "size-6" : "size-7 mr-2",
                    checked ? "bg-card border-primary text-primary" : "bg-card border-border text-foreground/80 hover:border-primary/40"
                )}
            >
                <AnimatePresence mode="wait">
                    {checked && (
                        <motion.span
                            key="check"
                            initial={{ scale: 0, opacity: 0 }}
                            animate={{ scale: 1, opacity: 1 }}
                            exit={{ scale: 0, opacity: 0 }}
                            transition={S_CHECK}
                        >
                            <Check size={isCard ? 14 : 16} strokeWidth={2.5} />
                        </motion.span>
                    )}
                </AnimatePresence>
            </motion.button>
        </motion.div>
    );
});

// ─── Main component ──────────────────────────────────────────────────────────

export interface AnimatedLayoutProps {
    items: CollectionItem[];
    defaultView?: ViewMode;
    heading?: string;
    className?: string;
}

export function AnimatedLayout({ items, defaultView = "list", heading = "My Tasks", className }: AnimatedLayoutProps) {
    const [view, setView] = useState<ViewMode>(defaultView);
    const len = items.length;

    return (
        <div className={cn("w-full max-w-xl mx-auto p-4 md:p-8 font-sans select-none", className)}>
            <div className="w-full flex flex-col items-end gap-4">
                <div className="flex items-center justify-between w-full gap-5">
                    <h2 className="text-xl font-medium text-foreground">{heading}</h2>
                    <nav className="flex p-1 bg-muted rounded-lg w-fit border border-border">
                        {TABS.map((t) => (
                            <ViewTab key={t.mode} active={view === t.mode} onClick={() => setView(t.mode)} icon={t.icon} />
                        ))}
                    </nav>
                </div>

                <div className={cn("relative w-full flex flex-col items-center border border-border rounded-xl bg-muted/60", WRAP_CLS[view])}>
                    <LayoutGroup>
                        <motion.div layout transition={S} style={GPU} className={cn("w-full relative", GRID_CLS[view])}>
                            {items.map((item, i) => (
                                <motion.div
                                    key={item.id}
                                    layout
                                    transition={S}
                                    style={{ ...GPU, zIndex: view === "pack" ? len - i : 1 }}
                                    animate={view === "pack" ? { rotate: 0, x: (i - (len - 1) / 2) * PACK_X } : RESET}
                                    className={cn("relative flex items-center bg-background p-1.5", CARD_CLS[view])}
                                >
                                    <motion.div layout transition={S} className={cn("relative overflow-hidden shrink-0 bg-background", IMG_CLS[view])}>
                                        <motion.img layout transition={S} src={item.image} alt={item.title} loading="lazy" decoding="async" className="w-full h-full object-cover block" />
                                    </motion.div>
                                    <AnimatePresence mode="popLayout" initial={false}>
                                        {view !== "pack" && <ItemInfo key={`${item.id}-info`} item={item} view={view} />}
                                    </AnimatePresence>
                                </motion.div>
                            ))}
                        </motion.div>
                    </LayoutGroup>
                </div>
            </div>
        </div>
    );
}

export default AnimatedLayout;

More from grootstudio

Browse all components in this library →