# Feature Grid

> Bento grid of six features where each tile runs a small live micro-animation — a typing command, a theme toggle, a moving focus ring, a rolling counter, springy bars and a clicking cursor — and lifts on hover.

A shadcn/ui block from the [@geturui](https://geturui.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/geturui/feature-grid).

- Page, with a live preview: https://shadcn.exchange/r/geturui/feature-grid
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/geturui/feature-grid-light-900a8f9054c611c0.png
- Registry item: https://geturui.com/r/feature-grid.json
- Categories: blocks, features, bento, grid, landing, micro-interaction, marketing
- Listed: 2026-10-23, source last changed 2026-10-23

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/geturui/feature-grid.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`
- Registry items: `@geturui/number-ticker`, `@geturui/switch`

## Files

### `registry/geturui/blocks/feature-grid.tsx`

```tsx
"use client";

import * as React from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
import { Activity, ChartColumn, Blocks, Keyboard, Palette, Terminal, MousePointer2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { NumberTicker } from "@/components/ui/number-ticker";
import { Switch } from "@/components/ui/switch";

/** Built-in micro-animations a tile can show. */
export type FeatureVisual = "typing" | "toggle" | "focus" | "ticker" | "bars" | "cursor";

export interface Feature {
  title: string;
  description: string;
  icon?: React.ReactNode;
  /** Built-in live illustration for the tile. */
  visual?: FeatureVisual;
  /** Your own illustration; overrides `visual`. */
  media?: React.ReactNode;
}

export interface FeatureGridProps {
  eyebrow?: string;
  heading?: string;
  subheading?: string;
  /** Six features fill the bento exactly; more wrap into extra rows. */
  features?: Feature[];
  className?: string;
}

function useTick(ms: number, active: boolean) {
  const [n, setN] = React.useState(0);
  React.useEffect(() => {
    if (!active) return;
    const t = setInterval(() => setN((x) => x + 1), ms);
    return () => clearInterval(t);
  }, [ms, active]);
  return n;
}

const COMMAND = "npx shadcn add @geturui/pricing-section";

function TypingVisual({ active }: { active: boolean }) {
  const tick = useTick(70, active);
  const cycle = COMMAND.length + 24;
  const shown = active ? Math.min(COMMAND.length, tick % cycle) : COMMAND.length;
  const done = shown === COMMAND.length;
  return (
    <div className="grid w-full max-w-sm gap-2 rounded-md border border-border bg-surface-sunken p-3 font-mono text-[12px] leading-5">
      <div className="flex items-center gap-1.5 text-ink">
        <span className="text-volt-ink">$</span>
        <span className="truncate">{COMMAND.slice(0, shown)}</span>
        <motion.span
          className="inline-block h-3.5 w-[7px] shrink-0 bg-volt"
          animate={active ? { opacity: [1, 0, 1] } : { opacity: 1 }}
          transition={{ duration: 0.9, repeat: Infinity, ease: "linear" }}
        />
      </div>
      <motion.div
        className="text-success"
        initial={false}
        animate={{ opacity: done ? 1 : 0, y: done ? 0 : 4 }}
        transition={{ type: "spring", stiffness: 400, damping: 30 }}
      >
        ✓ Added components/blocks/pricing-section.tsx
      </motion.div>
    </div>
  );
}

function ToggleVisual({ active }: { active: boolean }) {
  const tick = useTick(1800, active);
  const [manual, setManual] = React.useState<boolean | null>(null);
  const on = manual ?? tick % 2 === 0;
  return (
    <div className="flex w-full items-center justify-center gap-4">
      <motion.div
        aria-hidden
        className={cn("grid w-28 gap-1.5 rounded-md border p-2.5 transition-colors duration-300", on ? "border-border bg-ink" : "border-border bg-surface-raised")}
        animate={{ rotate: on ? -2 : 2 }}
        transition={{ type: "spring", stiffness: 300, damping: 20 }}
      >
        <span className={cn("h-1.5 w-12 rounded-full transition-colors duration-300", on ? "bg-bg/40" : "bg-ink/20")} />
        <span className={cn("h-1.5 w-16 rounded-full transition-colors duration-300", on ? "bg-bg/30" : "bg-ink/15")} />
        <span className="mt-1 h-4 w-10 rounded-full bg-volt" />
      </motion.div>
      <Switch
        checked={on}
        onCheckedChange={(v) => setManual(v)}
        aria-label="Preview theme toggle"
      />
    </div>
  );
}

const FOCUS_ITEMS = ["Tab", "Enter", "Esc"];

function FocusVisual({ active }: { active: boolean }) {
  const tick = useTick(1100, active);
  const ringId = React.useId();
  const at = active ? tick % FOCUS_ITEMS.length : 0;
  return (
    <div aria-hidden className="flex items-center gap-2.5">
      {FOCUS_ITEMS.map((k, i) => (
        <span key={k} className="relative">
          {at === i && (
            <motion.span
              layoutId={`focus-ring-${ringId}`}
              className="absolute -inset-1 rounded-[12px] shadow-[var(--focus-ring)]"
              transition={{ type: "spring", stiffness: 420, damping: 30 }}
            />
          )}
          <span
            className={cn(
              "relative inline-flex h-9 min-w-12 items-center justify-center rounded-sm border bg-surface-raised px-3 font-mono text-[12px] shadow-[inset_0_-2px_0_var(--border)] transition-colors",
              at === i ? "border-border-strong text-ink" : "border-border text-ink-muted",
            )}
          >
            {k}
          </span>
        </span>
      ))}
    </div>
  );
}

const TICKS = [48210, 48362, 48539, 48711, 48904];

function TickerVisual({ active }: { active: boolean }) {
  const tick = useTick(2200, active);
  const value = TICKS[tick % TICKS.length];
  return (
    <div className="grid justify-items-center gap-1">
      <NumberTicker value={value} className="font-display text-[34px] font-semibold tracking-[-1px] text-ink" />
      <span className="inline-flex items-center gap-1.5 text-[12px] font-medium text-ink-muted">
        <span className="size-1.5 rounded-full bg-success animate-pulse-dot text-success" aria-hidden /> weekly installs
      </span>
    </div>
  );
}

const BAR_SETS = [
  [40, 72, 56, 88, 64, 96, 78],
  [64, 48, 90, 60, 82, 54, 92],
  [52, 86, 70, 44, 94, 68, 80],
];

function BarsVisual({ active }: { active: boolean }) {
  const tick = useTick(1600, active);
  const bars = BAR_SETS[tick % BAR_SETS.length];
  return (
    <div aria-hidden className="flex h-24 w-full max-w-[220px] items-end gap-2">
      {bars.map((h, i) => (
        <motion.span
          key={i}
          className={cn("flex-1 origin-bottom rounded-t-[6px] rounded-b-[3px]", i === 5 ? "bg-volt" : "bg-surface-raised ring-1 ring-inset ring-border")}
          style={{ height: "100%" }}
          initial={false}
          animate={{ scaleY: h / 100 }}
          transition={{ type: "spring", stiffness: 220, damping: 12, delay: i * 0.03 }}
        />
      ))}
    </div>
  );
}

const CURSOR_SPOTS = [
  { x: 18, y: 26, target: 0 },
  { x: 112, y: 62, target: 1 },
  { x: 206, y: 26, target: 2 },
];

function CursorVisual({ active }: { active: boolean }) {
  const tick = useTick(1300, active);
  const spot = CURSOR_SPOTS[active ? tick % CURSOR_SPOTS.length : 1];
  const labels = ["Hero", "Pricing", "FAQ"];
  return (
    <div aria-hidden className="relative h-24 w-[260px]">
      {labels.map((l, i) => (
        <motion.span
          key={l}
          className={cn(
            "absolute inline-flex h-8 items-center gap-1.5 rounded-full border px-3 text-[12px] font-medium transition-colors duration-200",
            spot.target === i ? "border-transparent bg-volt text-on-volt" : "border-border bg-surface-raised text-ink-muted",
          )}
          style={{ left: i * 94, top: i === 1 ? 46 : 10 }}
          animate={{ scale: spot.target === i ? [1, 0.94, 1] : 1 }}
          transition={{ duration: 0.3, delay: 0.45 }}
        >
          <Blocks className="size-3" /> {l}
        </motion.span>
      ))}
      <motion.span
        className="absolute left-0 top-0 text-ink drop-shadow-[0_2px_4px_rgb(0_0_0/0.4)]"
        initial={false}
        animate={{ x: spot.x + 34, y: spot.y + 8 }}
        transition={{ type: "spring", stiffness: 160, damping: 20 }}
      >
        <MousePointer2 className="size-5 fill-ink" />
      </motion.span>
    </div>
  );
}

const VISUALS: Record<FeatureVisual, React.ComponentType<{ active: boolean }>> = {
  typing: TypingVisual,
  toggle: ToggleVisual,
  focus: FocusVisual,
  ticker: TickerVisual,
  bars: BarsVisual,
  cursor: CursorVisual,
};

const DEFAULT_FEATURES: Feature[] = [
  {
    title: "Copy, paste, own it",
    description: "One command drops the source into your repo. No runtime package, no lock-in.",
    icon: <Terminal className="size-4" />,
    visual: "typing",
  },
  {
    title: "Token-driven theming",
    description: "Every surface reads your CSS variables, so dark and light just work.",
    icon: <Palette className="size-4" />,
    visual: "toggle",
  },
  {
    title: "Keyboard first",
    description: "Arrow keys, Escape and visible focus rings on every interactive part.",
    icon: <Keyboard className="size-4" />,
    visual: "focus",
  },
  {
    title: "Used in production",
    description: "Teams ship GetUrUI in dashboards, docs and storefronts every week.",
    icon: <ChartColumn className="size-4" />,
    visual: "ticker",
  },
  {
    title: "Tuned springs",
    description: "Stiffness and damping picked per interaction, not one curve for everything.",
    icon: <Activity className="size-4" />,
    visual: "bars",
  },
  {
    title: "Blocks that compose",
    description: "Sections are built from the same primitives you already use, so edits stay small.",
    icon: <Blocks className="size-4" />,
    visual: "cursor",
  },
];

const SPANS = ["@xl:col-span-2 @4xl:col-span-2", "", "", "", "", "@xl:col-span-2 @4xl:col-span-2"];

function Tile({ feature, index }: { feature: Feature; index: number }) {
  const ref = React.useRef<HTMLElement>(null);
  const inView = useInView(ref, { margin: "-60px" });
  const reduce = useReducedMotion() ?? false;
  const Visual = feature.visual ? VISUALS[feature.visual] : null;
  return (
    <motion.article
      ref={ref}
      className={cn(
        "group/tile flex flex-col overflow-hidden rounded-lg border border-border bg-surface transition-[border-color,box-shadow] duration-200 hover:border-border-strong hover:shadow-md",
        SPANS[index % SPANS.length],
      )}
      initial={reduce ? { opacity: 0 } : { opacity: 0, y: 16 }}
      whileInView={{ opacity: 1, y: 0 }}
      viewport={{ once: true, margin: "-40px" }}
      whileHover={reduce ? undefined : { y: -4 }}
      transition={{ type: "spring", stiffness: 300, damping: 28, delay: (index % 3) * 0.06 }}
    >
      <div className="relative flex h-40 items-center justify-center overflow-hidden border-b border-border px-5 dot-grid">
        <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(closest-side,transparent,var(--surface))]" />
        <div className="relative flex w-full items-center justify-center">
          {feature.media ?? (Visual ? <Visual active={inView && !reduce} /> : null)}
        </div>
      </div>
      <div className="grid gap-1.5 p-5">
        <h3 className="flex items-center gap-2 text-[15px] font-semibold text-ink">
          {feature.icon && <span className="text-volt-ink">{feature.icon}</span>}
          {feature.title}
        </h3>
        <p className="text-sm leading-[22px] text-ink-muted">{feature.description}</p>
      </div>
    </motion.article>
  );
}

/** Bento grid of six features; each tile runs a small live micro-animation while in view and lifts on hover. */
export function FeatureGrid({
  eyebrow = "Why GetUrUI",
  heading = "Motion that feels considered, code that stays yours",
  subheading = "Everything you need to ship polished interfaces without hand-tuning every transition.",
  features = DEFAULT_FEATURES,
  className,
}: FeatureGridProps) {
  return (
    <section className={cn("@container w-full px-4 py-16 sm:px-6 sm:py-24", className)}>
      <div className="mx-auto grid max-w-6xl gap-12">
        <header className="mx-auto grid max-w-2xl gap-3 text-center">
          {eyebrow && <span className="text-[13px] font-semibold uppercase tracking-[1px] text-volt-ink">{eyebrow}</span>}
          <h2 className="text-balance font-display text-3xl font-semibold tracking-[-0.8px] text-ink @xl:text-[40px] @xl:leading-[1.1]">{heading}</h2>
          {subheading && <p className="text-balance text-base leading-7 text-ink-muted">{subheading}</p>}
        </header>
        <div className="grid grid-cols-1 gap-4 @xl:grid-cols-2 @4xl:grid-cols-3">
          {features.map((f, i) => (
            <Tile key={f.title} feature={f} index={i} />
          ))}
        </div>
      </div>
    </section>
  );
}
```

### `LICENSE-geturui.md`

```md
MIT License

Copyright (c) 2026 geturui.com

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
