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

- Collection: Blocks
- Tier: free
- Docs: https://geturui.com/docs/feature-grid
- Install: `npx shadcn@latest add @geturui/feature-grid`
- Dependencies: motion, lucide-react

## Usage

```tsx
import { FeatureGrid } from "@/components/blocks/feature-grid";
import { Zap } from "lucide-react";

export default function Features() {
  return (
    <FeatureGrid
      heading="Everything your team needs"
      features={[
        { title: "Instant deploys", description: "Push to main and you are live.", icon: <Zap className="size-4" />, visual: "typing" },
        { title: "Themes", description: "Dark and light from one set of tokens.", visual: "toggle" },
        { title: "Keyboard first", description: "Every action has a shortcut.", visual: "focus" },
        { title: "Growing fast", description: "New installs every week.", visual: "ticker" },
        { title: "Fast everywhere", description: "Edge rendering in 30 regions.", visual: "bars" },
        { title: "Composable", description: "Mix and match sections.", visual: "cursor" },
      ]}
    />
  );
}
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| eyebrow | `string` | "Why GetUrUI" | Small label above the heading. |
| heading | `string` | — | Section heading. |
| subheading | `string` | — | Supporting line under the heading. |
| features | `Feature[]` | — | { title, description, icon?, visual?, media? }. Six fill the bento; tiles 1 and 6 span two columns. |
| features[].visual | `"typing" | "toggle" | "focus" | "ticker" | "bars" | "cursor"` | — | Built-in live illustration for the tile. |
| features[].media | `ReactNode` | — | Your own illustration; overrides visual. |
| className | `string` | — | Classes for the outer section. |

## Accessibility

- Each tile is an article with a real heading; illustrations are decorative except the theme switch, which is labelled.
- Animations only run while a tile is in view and stop entirely under reduced motion.

## Source

### components/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>
  );
}

```
