Cards & Layoutv0.1.0
Bento Grid
Tiles lift on hover and animate their contents.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/bento-grid.jsonManual
Install the dependencies
npm install clsx lucide-react tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.ts. See Installation if you have not set them up yet.Copy the source
components/ui/bento-grid/bento-grid.tsximport type { ComponentProps, ElementType, ReactNode } from "react"; import { ArrowRight } from "lucide-react"; import { cn } from "@/lib/utils"; export type BentoGridProps = ComponentProps<"div">; /** A responsive grid for BentoTiles: one column on phones, two on tablets, three on desktops. */ export function BentoGrid({ className, ...props }: BentoGridProps) { return <div className={cn("grid auto-rows-[minmax(17rem,auto)] grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3", className)} {...props} />; } export interface BentoTileProps extends Omit<ComponentProps<"div">, "title"> { /** Tile heading. */ title: string; /** Supporting line under the title. */ description?: string; /** Small icon above the title. */ icon?: ReactNode; /** Makes the whole tile a link to this destination. */ href?: string; /** Text of the call to action that slides in on hover. @default "Learn more" */ cta?: string; /** Link component for href, e.g. Next.js Link. @default "a" */ linkComponent?: ElementType; /** Heading level of the title, to fit your page outline. @default 3 */ headingLevel?: 2 | 3 | 4; /** Visual area at the top; animate it with group-hover:/group-focus-within: classes. */ children?: ReactNode; } /** * A tile that lifts on hover (and when anything inside has keyboard focus). * It exposes Tailwind's `group`, so the visual can animate with group-hover: classes. */ export function BentoTile({ title, description, icon, href, cta = "Learn more", linkComponent: Link = "a", headingLevel = 3, className, children, ...props }: BentoTileProps) { const Heading = `h${headingLevel}` as const; return ( <div className={cn( "group relative flex flex-col overflow-hidden rounded-2xl border border-border bg-card text-card-foreground", "transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] hover:-translate-y-1 focus-within:-translate-y-1 motion-reduce:transition-none motion-reduce:hover:translate-y-0 motion-reduce:focus-within:translate-y-0", className, )} {...props} > {/* The lift's deeper shadow fades in as a layer, so only opacity animates. */} <span aria-hidden="true" className="pointer-events-none absolute inset-0 rounded-[inherit] opacity-0 shadow-lg transition-opacity duration-300 group-focus-within:opacity-100 group-hover:opacity-100" /> {children ? <div className="relative min-h-0 flex-1 overflow-hidden">{children}</div> : <div className="flex-1" />} <div className="relative p-5"> {icon ? <div className="mb-3 flex size-8 items-center justify-center rounded-lg border border-border bg-surface text-foreground [&_svg]:size-4">{icon}</div> : null} <Heading className="font-semibold tracking-tight text-foreground"> {href ? ( // The link stretches over the whole tile, so the tile is one large click target. <Link href={href} className="outline-none after:absolute after:inset-0 after:rounded-2xl focus-visible:after:ring-2 focus-visible:after:ring-ring"> {title} </Link> ) : ( title )} </Heading> {description ? <p className="mt-1 text-sm leading-6 text-muted-foreground">{description}</p> : null} {href ? ( <span aria-hidden="true" className="mt-3 inline-flex items-center gap-1 text-sm font-medium text-foreground opacity-0 transition-[opacity,translate] duration-300 -translate-x-1 group-focus-within:translate-x-0 group-focus-within:opacity-100 group-hover:translate-x-0 group-hover:opacity-100 max-sm:translate-x-0 max-sm:opacity-100" > {cta} <ArrowRight className="size-3.5" /> </span> ) : null} </div> </div> ); }
Usage
import { BentoGrid, BentoTile } from "@/components/ui/bento-grid/bento-grid";
export function Features() {
return (
<BentoGrid>
<BentoTile className="sm:col-span-2" title="Analytics" description="…" href="/analytics">
{/* Animate visuals with group-hover: classes */}
<div className="transition-transform group-hover:scale-105">…</div>
</BentoTile>
<BentoTile title="Notifications" description="…" />
</BentoGrid>
);
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
title* | string | – | Tile heading. |
description | string | – | Supporting line under the title. |
icon | ReactNode | – | Small icon above the title. |
href | string | – | Makes the whole tile a link to this destination. |
cta | string | "Learn more" | Text of the call to action that slides in on hover. |
linkComponent | ElementType | "a" | Link component for href, e.g. Next.js Link. |
headingLevel | 2 | 3 | 4 | 3 | Heading level of the title, to fit your page outline. |
children | ReactNode | – | Visual area at the top; animate it with group-hover:/group-focus-within: classes. |
Also accepts every native <div> attribute, including ref and className.
- Pure CSS with no JavaScript, so both pieces work as React Server Components.
- Each tile is a Tailwind group: style its visual with group-hover: and group-focus-within: to animate it on hover.
- The lift's deeper shadow is a separate layer that fades in, so only transform and opacity animate. Spans come from className (sm:col-span-2, row-span-2).
Accessibility
- With href, the title becomes a link stretched over the whole tile: one large target, one tab stop, and a focus ring around the tile.
- Keyboard focus lifts the tile and reveals the call to action, the same as hover.
- With reduced motion enabled, tiles don't lift; content animations should use motion-reduce: classes too.