ZENYNOZenith of Technology
Command Search
Search for a page
/ Components / ERP Module Grid

ERP Module Grid

Bento

Expandable 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.

bentogridmodalerpinteractive
Open preview
Live Preview
AI Generation Prompt

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>