ERP Module Grid
BentoExpandable bento-style grid of ERP module cards. Click any card to open a detailed modal with fade+scale animation. ESC or click-outside to close.
Create a Svelte 5 (runes) expandable bento grid component for ERP modules. Render a 3-column (lg) / 2-column (md) / 1-column (sm) grid of card buttons. Each card shows a 56×56px icon box (bg-blue-100 rounded-lg) with a Lucide icon and the item title + subtitle. Clicking a card opens a modal overlay. The modal has: a full-width icon banner (h-44, bg-blue-50), a title + description row with a 'Learn more' link, and scrollable detail text below. A close button (X icon, top-right, rounded-full) and clicking the backdrop both close the modal. ESC key also closes it. Animations: backdrop uses svelte/transition fade (180ms); modal panel uses scale({ start: 0.95, opacity: 0, duration: 220 }). Lock body scroll while modal is open via $effect. Icons are resolved from a string key ('finance', 'hr', 'inventory', etc.) mapped to imported Lucide Svelte components. Use $state for the active item, $effect for ESC handler and body overflow. Data is passed via a content prop with shape: { heading?, subtext?, items: { id, title, subtitle?, description?, content?, icon? }[] }.
<script lang="ts">
import { fade, scale } from 'svelte/transition';
import XIcon from '@lucide/svelte/icons/x';
import DollarSignIcon from '@lucide/svelte/icons/dollar-sign';
import UsersIcon from '@lucide/svelte/icons/users';
import PackageIcon from '@lucide/svelte/icons/package';
import TrendingUpIcon from '@lucide/svelte/icons/trending-up';
import WrenchIcon from '@lucide/svelte/icons/wrench';
import ShoppingCartIcon from '@lucide/svelte/icons/shopping-cart';
import KanbanIcon from '@lucide/svelte/icons/kanban';
import BarChart2Icon from '@lucide/svelte/icons/bar-chart-2';
import FileTextIcon from '@lucide/svelte/icons/file-text';
import ZapIcon from '@lucide/svelte/icons/zap';
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check';
type Item = {
id: string | number;
title: string;
subtitle?: string;
description?: string;
content?: string;
icon?: string;
};
type Props = {
content: {
heading?: string;
subtext?: string;
items: Item[];
};
};
let { content }: Props = $props();
let active = $state<Item | null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const iconMap: Record<string, any> = {
finance: DollarSignIcon,
hr: UsersIcon,
inventory: PackageIcon,
sales: TrendingUpIcon,
manufacturing: WrenchIcon,
procurement: ShoppingCartIcon,
projects: KanbanIcon,
analytics: BarChart2Icon,
reports: FileTextIcon,
automation: ZapIcon,
compliance: ShieldCheckIcon,
};
$effect(() => {
document.body.style.overflow = active ? 'hidden' : '';
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') active = null;
};
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('keydown', onKey);
document.body.style.overflow = '';
};
});
</script>
{#if active}
<!-- Backdrop -->
<div
transition:fade={{ duration: 180 }}
class="fixed inset-0 bg-black/30 z-[10000]"
onclick={() => (active = null)}
role="presentation"
></div>
<!-- Click-outside wrapper -->
<div
class="fixed inset-0 grid place-items-center z-[10001] p-4"
onclick={() => (active = null)}
role="presentation"
>
<!-- Modal panel -->
<div
transition:scale={{ duration: 220, start: 0.95, opacity: 0 }}
onclick={(e) => e.stopPropagation()}
class="relative w-full max-w-[500px] max-h-[88vh] flex flex-col bg-white dark:bg-neutral-900 rounded-3xl overflow-hidden shadow-2xl"
>
<!-- Close button -->
<button
onclick={() => (active = null)}
class="absolute top-3 right-3 z-10 flex h-7 w-7 items-center justify-center rounded-full bg-neutral-100 dark:bg-neutral-800 hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors"
aria-label="Close"
>
<XIcon class="h-4 w-4 text-neutral-700 dark:text-neutral-300" />
</button>
<!-- Icon banner -->
<div class="w-full h-44 flex-shrink-0 bg-blue-50 dark:bg-blue-900/20 flex items-center justify-center">
{#if active.icon && iconMap[active.icon]}
{@const Icon = iconMap[active.icon]}
<Icon class="size-16 text-blue-500 dark:text-blue-400" />
{/if}
</div>
<!-- Body -->
<div class="flex-1 overflow-y-auto">
<div class="flex items-start justify-between gap-4 p-5 pb-3">
<div>
<h3 class="font-bold text-neutral-800 dark:text-neutral-100 text-lg leading-snug">{active.title}</h3>
{#if active.description}
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">{active.description}</p>
{/if}
</div>
<a
href="#"
class="shrink-0 rounded-2xl bg-blue-500 hover:bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition-colors"
>
Learn more
</a>
</div>
{#if active.content}
<p class="px-5 pb-6 text-sm leading-relaxed text-neutral-600 dark:text-neutral-400">
{active.content}
</p>
{/if}
</div>
</div>
</div>
{/if}
<!-- Grid -->
<section class="w-full py-16 px-4">
{#if content.heading || content.subtext}
<div class="text-center mb-10 max-w-2xl mx-auto">
{#if content.heading}
<h2 class="text-3xl font-bold text-neutral-800 dark:text-neutral-100 mb-3">{content.heading}</h2>
{/if}
{#if content.subtext}
<p class="text-neutral-500 dark:text-neutral-400">{content.subtext}</p>
{/if}
</div>
{/if}
<ul class="max-w-4xl mx-auto w-full list-none grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 lg:gap-4 items-start">
{#each content.items as item (item.id)}
<li>
<button
onclick={() => (active = item)}
class="w-full p-4 flex flex-row gap-3 items-center text-left rounded-xl cursor-pointer bg-blue-50/60 dark:bg-blue-900/10 border border-blue-100 dark:border-blue-800 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors"
>
<div class="h-14 w-14 shrink-0 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-500 dark:text-blue-400">
{#if item.icon && iconMap[item.icon]}
{@const Icon = iconMap[item.icon]}
<Icon class="size-7" />
{/if}
</div>
<div class="min-w-0">
<p class="font-medium text-neutral-800 dark:text-neutral-200 text-sm">{item.title}</p>
{#if item.subtitle}
<p class="mt-0.5 text-xs text-neutral-500 dark:text-neutral-400 line-clamp-2">{item.subtitle}</p>
{/if}
</div>
</button>
</li>
{/each}
</ul>
</section>