Vue componentFree

Bento Grid

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

Install
Full screen ↗

About this component

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.

  • Wide and tall tile sizes
  • 6 CSS-drawn mini visuals
  • Scoped visual slot per tile
  • 1 / 2 / 3 column responsive grid
  • Light and dark mode

Code

11 files in the repo
<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>

Props

PropTypeDefaultDescription
eyebrowstring'Monitoring, minus the noise'Small accent label above the heading.
headingstring'Know it is down before your customers do'Section heading.
descriptionstring'Pinglight watches your sites…'Intro paragraph. Empty string hides it.
tilesBentoTile[]6 monitoring tilesTiles with eyebrow, title, description, size ('normal' | 'wide' | 'tall') and visual ('map' | 'timeline' | 'uptime' | 'status' | 'oncall' | 'code').

Slots

SlotDescription
visualReplaces a tile's drawn visual. Receives { tile, index }.
All feature sections →
Preview of Feature GridFree

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.

by Vueplay
Preview of Feature AlternatingFree

Component · Feature sections

Feature Alternating

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.

by Vueplay
Preview of Logo CloudFree

Component · Feature sections

Logo Cloud

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

by Vueplay
Preview of Hero SplitFree

Component · Hero sections

Hero Split

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.

by Vueplay1 installs

Pro feature

Upgrade to Pro

Upgrade to unlock more of Vueplay. Cancel any time.

  • Custom domains with HTTPS
  • Private projects
  • No "Made with Vueplay" badge
  • GitHub sync

$120/year

Compare plansBilling settings