shadimage

Bento

source cached 2026-09-24

registry/designali/ui/bento.tsx

"use client"

import { useEffect, useState, type CSSProperties, type ReactNode } from "react"

import { cn } from "@workspace/ui/lib/utils"

/* -------------------------------------------------------------------------- */
/*                                   Types                                    */
/* -------------------------------------------------------------------------- */

type Breakpoint = "base" | "sm" | "md" | "lg" | "xl" | "2xl"
type ResponsiveValue<T> = T | Partial<Record<Breakpoint, T>>
const BREAKPOINTS: Breakpoint[] = ["base", "sm", "md", "lg", "xl", "2xl"]

/* -------------------------------------------------------------------------- */
/*             Fully Static Responsive Maps (Tailwind JIT safe)               */
/*                                                                            */
/*  Tailwind's scanner reads source TEXT — it does NOT execute code.          */
/*  Every class that might be emitted MUST appear as a complete literal       */
/*  string somewhere in the scanned files.                                    */
/*                                                                            */
/*  Dynamic construction like `${bp}:col-span-${n}` or even               */
/*  `${bp}:${map[value]}` is invisible to the scanner — Tailwind only       */
/*  sees the template literal syntax, not what it evaluates to.             */
/*                                                                            */
/*  These nested objects are the single source of truth. Every possible       */
/*  responsive class is written out verbatim so JIT includes them all.        */
/* -------------------------------------------------------------------------- */

const RESPONSIVE_GRID_COLS = {
  base: {
    1: "grid-cols-1",
    2: "grid-cols-2",
    3: "grid-cols-3",
    4: "grid-cols-4",
    5: "grid-cols-5",
    6: "grid-cols-6",
    7: "grid-cols-7",
    8: "grid-cols-8",
  },
  sm: {
    1: "sm:grid-cols-1",
    2: "sm:grid-cols-2",
    3: "sm:grid-cols-3",
    4: "sm:grid-cols-4",
    5: "sm:grid-cols-5",
    6: "sm:grid-cols-6",
    7: "sm:grid-cols-7",
    8: "sm:grid-cols-8",
  },
  md: {
    1: "md:grid-cols-1",
    2: "md:grid-cols-2",
    3: "md:grid-cols-3",
    4: "md:grid-cols-4",
    5: "md:grid-cols-5",
    6: "md:grid-cols-6",
    7: "md:grid-cols-7",
    8: "md:grid-cols-8",
  },
  lg: {
    1: "lg:grid-cols-1",
    2: "lg:grid-cols-2",
    3: "lg:grid-cols-3",
    4: "lg:grid-cols-4",
    5: "lg:grid-cols-5",
    6: "lg:grid-cols-6",
    7: "lg:grid-cols-7",
    8: "lg:grid-cols-8",
  },
  xl: {
    1: "xl:grid-cols-1",
    2: "xl:grid-cols-2",
    3: "xl:grid-cols-3",
    4: "xl:grid-cols-4",
    5: "xl:grid-cols-5",
    6: "xl:grid-cols-6",
    7: "xl:grid-cols-7",
    8: "xl:grid-cols-8",
  },
  "2xl": {
    1: "2xl:grid-cols-1",
    2: "2xl:grid-cols-2",
    3: "2xl:grid-cols-3",
    4: "2xl:grid-cols-4",
    5: "2xl:grid-cols-5",
    6: "2xl:grid-cols-6",
    7: "2xl:grid-cols-7",
    8: "2xl:grid-cols-8",
  },
} as const

const RESPONSIVE_COL_SPAN = {
  base: {
    1: "col-span-1",
    2: "col-span-2",
    3: "col-span-3",
    4: "col-span-4",
    5: "col-span-5",
  },
  sm: {
    1: "sm:col-span-1",
    2: "sm:col-span-2",
    3: "sm:col-span-3",
    4: "sm:col-span-4",
    5: "sm:col-span-5",
  },
  md: {
    1: "md:col-span-1",
    2: "md:col-span-2",
    3: "md:col-span-3",
    4: "md:col-span-4",
    5: "md:col-span-5",
  },
  lg: {
    1: "lg:col-span-1",
    2: "lg:col-span-2",
    3: "lg:col-span-3",
    4: "lg:col-span-4",
    5: "lg:col-span-5",
  },
  xl: {
    1: "xl:col-span-1",
    2: "xl:col-span-2",
    3: "xl:col-span-3",
    4: "xl:col-span-4",
    5: "xl:col-span-5",
  },
  "2xl": {
    1: "2xl:col-span-1",
    2: "2xl:col-span-2",
    3: "2xl:col-span-3",
    4: "2xl:col-span-4",
    5: "2xl:col-span-5",
  },
} as const

const RESPONSIVE_ROW_SPAN = {
  base: {
    1: "row-span-1",
    2: "row-span-2",
    3: "row-span-3",
    4: "row-span-4",
    5: "row-span-5",
  },
  sm: {
    1: "sm:row-span-1",
    2: "sm:row-span-2",
    3: "sm:row-span-3",
    4: "sm:row-span-4",
    5: "sm:row-span-5",
  },
  md: {
    1: "md:row-span-1",
    2: "md:row-span-2",
    3: "md:row-span-3",
    4: "md:row-span-4",
    5: "md:row-span-5",
  },
  lg: {
    1: "lg:row-span-1",
    2: "lg:row-span-2",
    3: "lg:row-span-3",
    4: "lg:row-span-4",
    5: "lg:row-span-5",
  },
  xl: {
    1: "xl:row-span-1",
    2: "xl:row-span-2",
    3: "xl:row-span-3",
    4: "xl:row-span-4",
    5: "xl:row-span-5",
  },
  "2xl": {
    1: "2xl:row-span-1",
    2: "2xl:row-span-2",
    3: "2xl:row-span-3",
    4: "2xl:row-span-4",
    5: "2xl:row-span-5",
  },
} as const

const RESPONSIVE_GAP = {
  base: {
    0: "gap-0",
    1: "gap-1",
    2: "gap-2",
    3: "gap-3",
    4: "gap-4",
    5: "gap-5",
    6: "gap-6",
    8: "gap-8",
    10: "gap-10",
  },
  sm: {
    0: "sm:gap-0",
    1: "sm:gap-1",
    2: "sm:gap-2",
    3: "sm:gap-3",
    4: "sm:gap-4",
    5: "sm:gap-5",
    6: "sm:gap-6",
    8: "sm:gap-8",
    10: "sm:gap-10",
  },
  md: {
    0: "md:gap-0",
    1: "md:gap-1",
    2: "md:gap-2",
    3: "md:gap-3",
    4: "md:gap-4",
    5: "md:gap-5",
    6: "md:gap-6",
    8: "md:gap-8",
    10: "md:gap-10",
  },
  lg: {
    0: "lg:gap-0",
    1: "lg:gap-1",
    2: "lg:gap-2",
    3: "lg:gap-3",
    4: "lg:gap-4",
    5: "lg:gap-5",
    6: "lg:gap-6",
    8: "lg:gap-8",
    10: "lg:gap-10",
  },
  xl: {
    0: "xl:gap-0",
    1: "xl:gap-1",
    2: "xl:gap-2",
    3: "xl:gap-3",
    4: "xl:gap-4",
    5: "xl:gap-5",
    6: "xl:gap-6",
    8: "xl:gap-8",
    10: "xl:gap-10",
  },
  "2xl": {
    0: "2xl:gap-0",
    1: "2xl:gap-1",
    2: "2xl:gap-2",
    3: "2xl:gap-3",
    4: "2xl:gap-4",
    5: "2xl:gap-5",
    6: "2xl:gap-6",
    8: "2xl:gap-8",
    10: "2xl:gap-10",
  },
} as const

/* -------------------------------------------------------------------------- */
/*                      Responsive Class Generator                            */
/* -------------------------------------------------------------------------- */

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ResponsiveMap<T extends keyof any> = Record<Breakpoint, Record<T, string>>

function getResponsiveClasses<T extends number>(
  value: ResponsiveValue<T> | undefined,
  map: ResponsiveMap<T>
): string {
  if (value === undefined) return ""

  if (typeof value !== "object") {
    return map.base[value] ?? ""
  }

  return BREAKPOINTS.filter(
    (bp) => (value as Partial<Record<Breakpoint, T>>)[bp] !== undefined
  )
    .map((bp) => {
      const key = (value as Partial<Record<Breakpoint, T>>)[bp] as T
      return map[bp]?.[key] ?? ""
    })
    .filter(Boolean)
    .join(" ")
}

/* -------------------------------------------------------------------------- */
/*                     Responsive Row Height Hook                             */
/* -------------------------------------------------------------------------- */

function useResponsiveRowHeight(
  value: ResponsiveValue<string> | undefined
): CSSProperties | undefined {
  const [rowHeight, setRowHeight] = useState<string | undefined>(
    typeof value === "string" ? value : value?.base
  )

  useEffect(() => {
    if (!value || typeof value === "string") return

    const queries: Record<Exclude<Breakpoint, "base">, MediaQueryList> = {
      sm: window.matchMedia("(min-width: 640px)"),
      md: window.matchMedia("(min-width: 768px)"),
      lg: window.matchMedia("(min-width: 1024px)"),
      xl: window.matchMedia("(min-width: 1280px)"),
      "2xl": window.matchMedia("(min-width: 1536px)"),
    }

    const update = () => {
      const ordered: Breakpoint[] = ["2xl", "xl", "lg", "md", "sm"]
      for (const bp of ordered) {
        if (queries[bp as keyof typeof queries].matches && value[bp]) {
          setRowHeight(value[bp])
          return
        }
      }
      setRowHeight(value.base)
    }

    update()
    Object.values(queries).forEach((q) => q.addEventListener("change", update))
    return () =>
      Object.values(queries).forEach((q) =>
        q.removeEventListener("change", update)
      )
  }, [value])

  return rowHeight ? { gridAutoRows: rowHeight } : undefined
}

/* -------------------------------------------------------------------------- */
/*                               BentoGrid                                    */
/* -------------------------------------------------------------------------- */

type GridColsValue = keyof typeof RESPONSIVE_GRID_COLS.base
type GapValue = keyof typeof RESPONSIVE_GAP.base

interface BentoGridProps {
  children: ReactNode
  className?: string
  cols?: ResponsiveValue<GridColsValue>
  gap?: ResponsiveValue<GapValue>
  rowHeight?: ResponsiveValue<string>
}

export function BentoGrid({
  children,
  className,
  cols = { base: 1, md: 3, lg: 5 },
  gap = { base: 1, md: 2, lg: 4 },
  rowHeight = { base: "60px", md: "80px", lg: "100px" },
}: BentoGridProps) {
  const style = useResponsiveRowHeight(rowHeight)
  return (
    <div
      style={style}
      className={cn(
        "mx-auto grid h-full w-full",
        getResponsiveClasses(
          cols,
          RESPONSIVE_GRID_COLS as ResponsiveMap<GridColsValue>
        ),
        getResponsiveClasses(gap, RESPONSIVE_GAP as ResponsiveMap<GapValue>),
        className
      )}
    >
      {children}
    </div>
  )
}

/* -------------------------------------------------------------------------- */
/*                             BentoGridItem                                  */
/* -------------------------------------------------------------------------- */

type ColSpanValue = keyof typeof RESPONSIVE_COL_SPAN.base
type RowSpanValue = keyof typeof RESPONSIVE_ROW_SPAN.base

interface BentoGridItemProps {
  children: ReactNode
  className?: string
  colSpan?: ResponsiveValue<ColSpanValue>
  rowSpan?: ResponsiveValue<RowSpanValue>
}

export function BentoGridItem({
  children,
  className,
  colSpan = 1,
  rowSpan = 1,
}: BentoGridItemProps) {
  return (
    <div
      className={cn(
        "border bg-background p-4 transition-transform",
        getResponsiveClasses(
          colSpan,
          RESPONSIVE_COL_SPAN as ResponsiveMap<ColSpanValue>
        ),
        getResponsiveClasses(
          rowSpan,
          RESPONSIVE_ROW_SPAN as ResponsiveMap<RowSpanValue>
        ),
        className
      )}
    >
      {children}
    </div>
  )
}
Where it goes
bento-grid
Visual style
bento