Component · Feature sections
Feature Grid
Responsive Vue 3 feature grid with a heading, inline SVG icons and plain or card variants in 2, 3 or 4 columns. Tailwind CSS, dark mode, typed data.
Bento-style Vue 3 feature grid with wide and tall tiles and small CSS-drawn visuals like maps, timelines and code. Tailwind CSS, dark mode, typed tiles.
by Vueplayv1.0.0Updated 2 hours ago
The bento layout made popular by modern product launches: a grid of feature tiles in different sizes, each with a small visual on top and a short explanation below. It shows off several features at once without turning into a wall of text.
Tiles are plain data. Mark one wide to span two columns or tall to span two rows, and pick one of six built-in visuals (map, timeline, uptime, status, oncall, code). They are drawn with Tailwind utilities and inline markup, so they theme with dark mode and respect reduced motion. Use the scoped visual slot to render your own screenshot or chart in any tile.
The grid is three columns on desktop, two on tablets and a single column on phones, where every tile keeps its visual.
<script setup lang="ts">
export type BentoVisual = 'map' | 'timeline' | 'uptime' | 'status' | 'oncall' | 'code'
export interface BentoTile {
eyebrow?: string
title: string
description: string
/** 'wide' spans two columns, 'tall' spans two rows (desktop only). */
size?: 'normal' | 'wide' | 'tall'
visual?: BentoVisual
}
withDefaults(defineProps<{
eyebrow?: string
heading?: string
description?: string
tiles?: BentoTile[]
}>(), {
eyebrow: 'Monitoring, minus the noise',
heading: 'Know it is down before your customers do',
description: 'Pinglight watches your sites, APIs and cron jobs from around the world and tells the right person, once, with the context they need.',
tiles: () => [
{ eyebrow: 'Coverage', title: 'Checks from 18 regions every 30 seconds', description: 'Catch regional outages and slow DNS that a single probe would miss.', size: 'wide', visual: 'map' },
{ eyebrow: 'Incidents', title: 'A timeline that writes itself', description: 'Every alert, acknowledgement and deploy lands on one timeline, ready for the postmortem.', size: 'tall', visual: 'timeline' },
{ eyebrow: 'Uptime', title: 'Every check, day by day', description: 'Spot flaky endpoints long before they become incidents.', visual: 'uptime' },
{ eyebrow: 'Status pages', title: 'Keep customers in the loop', description: 'Branded public status pages that update automatically.', visual: 'status' },
{ eyebrow: 'On-call', title: 'Fair rotations', description: 'Schedules, overrides and escalations your team agrees on.', visual: 'oncall' },
{ eyebrow: 'Developers', title: 'Monitoring as code', description: 'Define checks next to your app and ship them with every deploy. Pinglight picks them up from CI.', size: 'wide', visual: 'code' },
],
})
defineSlots<{
visual?: (props: { tile: BentoTile, index: number }) => unknown
}>()
const sizeClass: Record<NonNullable<BentoTile['size']>, string> = {
normal: '',
wide: 'md:col-span-2',
tall: 'lg:row-span-2',
}
// Map dots: [x%, y%, isActiveRegion]
const regions: [number, number, boolean][] = [
[18, 38, true], [26, 52, false], [30, 70, true], [47, 32, true], [51, 44, false], [55, 64, true], [68, 36, false], [74, 48, true], [82, 70, true], [62, 26, false],
]
const uptime = Array.from({ length: 45 }, (_, i) => (i === 17 ? 'down' : i === 31 || i === 32 ? 'degraded' : 'up'))
const timeline = [
{ time: '14:02', label: 'Checkout API returning 502', tone: 'bg-rose-500' },
{ time: '14:03', label: 'Alert sent to Priya (on-call)', tone: 'bg-amber-500' },
{ time: '14:05', label: 'Acknowledged', tone: 'bg-fuchsia-500' },
{ time: '14:09', label: 'Rollback of deploy #812', tone: 'bg-fuchsia-500' },
{ time: '14:11', label: 'Resolved · 9 min', tone: 'bg-emerald-500' },
]
</script>
<template>
<section class="px-6 py-20 sm:py-24 lg:px-8">
<div class="mx-auto max-w-2xl text-center">
<p v-if="eyebrow" class="text-sm font-semibold text-fuchsia-700 dark:text-fuchsia-400">{{ eyebrow }}</p>
<h2 class="mt-2 text-3xl font-bold tracking-tight text-balance text-gray-900 sm:text-4xl dark:text-white">{{ heading }}</h2>
<p v-if="description" class="mt-4 text-lg text-pretty text-gray-600 dark:text-gray-400">{{ description }}</p>
</div>
<ul class="mx-auto mt-14 grid max-w-6xl grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 lg:gap-5">
<li
v-for="(tile, index) in tiles"
:key="tile.title"
class="flex flex-col overflow-hidden rounded-3xl bg-white ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-gray-800"
:class="sizeClass[tile.size ?? 'normal']"
>
<div class="relative flex min-h-44 flex-1 items-center justify-center overflow-hidden bg-gradient-to-b from-fuchsia-50/70 to-white px-6 pt-6 dark:from-fuchsia-500/[0.07] dark:to-gray-900" aria-hidden="true">
<slot name="visual" :tile="tile" :index="index">
<!-- Map: dotted world with pulsing probe regions -->
<div v-if="tile.visual === 'map'" class="relative h-40 w-full max-w-lg">
<div class="absolute inset-0 bg-[radial-gradient(circle,rgb(0_0_0/0.14)_1px,transparent_1.5px)] mask-[radial-gradient(ellipse_at_center,black_40%,transparent_75%)] bg-size-[12px_12px] dark:bg-[radial-gradient(circle,rgb(255_255_255/0.16)_1px,transparent_1.5px)]" />
<span
v-for="([x, y, active], i) in regions"
:key="i"
class="absolute -translate-x-1/2 -translate-y-1/2"
:style="{ left: `${x}%`, top: `${y}%` }"
>
<span v-if="active" class="absolute inset-0 animate-ping rounded-full bg-fuchsia-400/60 motion-reduce:animate-none" />
<span class="relative block size-2.5 rounded-full ring-2 ring-white dark:ring-gray-900" :class="active ? 'bg-fuchsia-500' : 'bg-gray-400 dark:bg-gray-500'" />
</span>
<span class="absolute top-[4%] left-[47%] rounded-md bg-gray-900 px-2 py-1 text-[10px] font-medium whitespace-nowrap text-white shadow dark:bg-white dark:text-gray-900">Frankfurt · 38 ms</span>
</div>
<!-- Timeline: incident events -->
<ol v-else-if="tile.visual === 'timeline'" class="w-full max-w-xs space-y-0 py-2">
<li v-for="(e, i) in timeline" :key="e.time" class="relative flex gap-3 pb-5 last:pb-0">
<span v-if="i < timeline.length - 1" class="absolute top-3 left-[5px] h-full w-px bg-gray-200 dark:bg-gray-700" />
<span class="relative mt-1 size-[11px] shrink-0 rounded-full ring-4 ring-white dark:ring-gray-900" :class="e.tone" />
<div class="min-w-0">
<p class="font-mono text-[11px] text-gray-400">{{ e.time }}</p>
<p class="text-xs font-medium text-gray-800 dark:text-gray-200">{{ e.label }}</p>
</div>
</li>
</ol>
<!-- Uptime bars -->
<div v-else-if="tile.visual === 'uptime'" class="w-full">
<div class="flex items-baseline justify-between">
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">api.example.com</span>
<span class="text-xs font-semibold text-emerald-600 dark:text-emerald-400">99.98%</span>
</div>
<div class="mt-2 flex h-10 gap-[3px]">
<span
v-for="(s, i) in uptime"
:key="i"
class="flex-1 rounded-[2px]"
:class="s === 'up' ? 'bg-emerald-400 dark:bg-emerald-500/80' : s === 'down' ? 'bg-rose-500' : 'bg-amber-400'"
/>
</div>
</div>
<!-- Status page card -->
<div v-else-if="tile.visual === 'status'" class="w-full max-w-xs rounded-xl bg-white p-3 shadow-lg ring-1 ring-gray-900/5 dark:bg-gray-800 dark:ring-white/10">
<div class="flex items-center gap-2 rounded-lg bg-emerald-50 px-3 py-2 text-xs font-semibold text-emerald-800 dark:bg-emerald-400/10 dark:text-emerald-300">
<span class="size-2 rounded-full bg-emerald-500" /> All systems operational
</div>
<div v-for="svc in ['Website', 'API', 'Webhooks']" :key="svc" class="flex items-center justify-between px-1 pt-2.5 text-xs text-gray-600 dark:text-gray-300">
{{ svc }}
<span class="size-1.5 rounded-full bg-emerald-500" />
</div>
</div>
<!-- On-call rotation -->
<div v-else-if="tile.visual === 'oncall'" class="w-full max-w-xs space-y-2">
<div
v-for="(p, i) in [{ i: 'PS', n: 'Priya S.', w: 'This week', on: true }, { i: 'DM', n: 'Dan M.', w: 'Next week', on: false }, { i: 'AO', n: 'Ama O.', w: 'Oct 21', on: false }]"
:key="p.i"
class="flex items-center gap-3 rounded-xl px-3 py-2"
:class="p.on ? 'bg-white shadow-md ring-1 ring-fuchsia-500/30 dark:bg-gray-800' : 'opacity-70'"
>
<span class="grid size-7 place-items-center rounded-full text-[10px] font-semibold text-white" :class="['bg-fuchsia-600', 'bg-gray-500', 'bg-gray-400'][i]">{{ p.i }}</span>
<span class="text-xs font-medium text-gray-800 dark:text-gray-200">{{ p.n }}</span>
<span class="ml-auto text-[11px]" :class="p.on ? 'font-semibold text-fuchsia-700 dark:text-fuchsia-300' : 'text-gray-500 dark:text-gray-400'">{{ p.w }}</span>
</div>
</div>
<!-- Code snippet -->
<pre v-else-if="tile.visual === 'code'" class="w-full max-w-lg self-end overflow-hidden rounded-t-xl bg-gray-950 p-4 font-mono text-[11px] leading-5 text-gray-300 shadow-xl ring-1 ring-white/10 sm:text-xs"><span class="text-fuchsia-400">export default</span> <span class="text-sky-300">check</span>({
name: <span class="text-emerald-300">'Checkout API'</span>,
url: <span class="text-emerald-300">'https://api.example.com/health'</span>,
every: <span class="text-emerald-300">'30s'</span>,
regions: [<span class="text-emerald-300">'eu'</span>, <span class="text-emerald-300">'us'</span>, <span class="text-emerald-300">'ap'</span>],
alert: { after: <span class="text-amber-300">2</span>, notify: <span class="text-emerald-300">'#oncall'</span> },
})</pre>
</slot>
</div>
<div class="p-6 pt-5">
<p v-if="tile.eyebrow" class="text-xs font-semibold tracking-wide text-fuchsia-700 uppercase dark:text-fuchsia-400">{{ tile.eyebrow }}</p>
<h3 class="mt-1.5 text-lg font-semibold text-gray-900 dark:text-white">{{ tile.title }}</h3>
<p class="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-400">{{ tile.description }}</p>
</div>
</li>
</ul>
</section>
</template>
| Prop | Type | Default | Description |
|---|---|---|---|
| eyebrow | string | 'Monitoring, minus the noise' | Small accent label above the heading. |
| heading | string | 'Know it is down before your customers do' | Section heading. |
| description | string | 'Pinglight watches your sites…' | Intro paragraph. Empty string hides it. |
| tiles | BentoTile[] | 6 monitoring tiles | Tiles with eyebrow, title, description, size ('normal' | 'wide' | 'tall') and visual ('map' | 'timeline' | 'uptime' | 'status' | 'oncall' | 'code'). |
| Slot | Description |
|---|---|
| visual | Replaces a tile's drawn visual. Receives { tile, index }. |
Component · Feature sections
Responsive Vue 3 feature grid with a heading, inline SVG icons and plain or card variants in 2, 3 or 4 columns. Tailwind CSS, dark mode, typed data.
Component · Feature sections
Alternating Vue 3 feature rows that zigzag text and CSS-drawn illustrations, with bullets and links per row. Tailwind CSS, dark mode, no images needed.
Component · Feature sections
"Trusted by" logo cloud for Vue 3 with a bordered grid or an endless marquee, using text wordmarks and SVG marks. Tailwind CSS, dark mode, reduced motion.
Component · Hero sections
Split Vue 3 hero section with copy and two CTAs on the left and a Tailwind-drawn app window mockup on the right. Dark mode, typed props, no images.