Magic Bento | 指针感知信息网格

Magic Bento 把信息卡片、指针位置、局部高光和点击选择合成一个轻量 React 交互案例,用于展示复杂内容入口如何保持可扫读、可探索和可确认。

效果展示

Loading interaction...

设计说明

场景

Bento grid 经常被当作视觉排版,但交互价值在于“帮助用户理解一组入口之间的层级”。普通卡片列表只能展示标题和描述,Magic Bento 则把指针位置转化成局部反馈:用户还没有点击时,界面已经告诉他哪张卡片正在响应。

这个案例适合放在技术博客的 Lab 中,因为它不是完整组件库能力,也不是大型产品功能。它只是一个小交互:扫描一组信息、悬停观察、点击确认。

交互设计

Magic Bento 的核心是三层状态:

Pointer focus

指针进入某张卡片时,只在该卡片内显示局部径向高光,避免整个网格都被动画打扰。

Selected intent

点击卡片后保留选中状态,让“浏览”变成“决策”。

Quiet density

卡片之间保持紧凑,但每张卡片都有明确标题、编号、说明和行动反馈,适合技术内容入口。

实现原理

最小实现不需要 Framer Motion 或其他动画库。React 只保存两个状态:当前指针所在卡片的局部坐标,以及当前选中的卡片 id。每张卡片在 onPointerMove 中用 getBoundingClientRect() 计算局部坐标,再把坐标写进内联 radial-gradient()

样式只使用已有 Tailwind utility 和站点主题 token。高光不是单独的 CSS 类,也不依赖 Tailwind 自定义解析;它只是一个 style.background 字符串。

SEO 与内容定位

这个案例的关键词不是“炫酷卡片”,而是“pointer-aware card”、“bento grid”、“React interaction case”。它更适合作为前端交互工程案例:解释如何把视觉高光、可访问按钮、选中状态和移动端布局收敛成一个可复用的小模式。

测试说明

测试时优先确认四件事:移动端网格不横向溢出,键盘焦点能进入每张卡片,点击后 aria-pressed 与视觉状态一致,高光层不会拦截点击或改变布局尺寸。

可复制源码

源码从案例目录的 examples 文件读取,展示内容和参与 typecheck 的代码保持同一事实来源。

"use client"; import * as React from "react"; const bentoItems = [  {    id: "intent",    title: "Intent",    value: "01",    body: "Start with the user action, not the visual effect.",  },  {    id: "feedback",    title: "Feedback",    value: "02",    body: "The hovered card explains what will respond before selection.",  },  {    id: "boundary",    title: "Boundary",    value: "03",    body: "Pointer state follows the nearest card, so gaps keep the glow alive.",  },  {    id: "motion",    title: "Motion",    value: "04",    body: "Keep animation local and readable so the glow supports attention.",  },  {    id: "density",    title: "Density",    value: "05",    body: "Use compact rhythm to show more choices while each item stays clear.",  },  {    id: "decision",    title: "Decision",    value: "06",    body: "Clicking pins the chosen card and turns exploration into a choice.",  },]; const glowRadius = 180;const edgeActivationDistance = 24;const hoverGlowColor = "rgb(var(--lab-hover-purple-rgb) / 0.28)";const borderBeamColor = "rgb(var(--lab-hover-purple-rgb) / 0.72)";const borderBeamMaskStyle: React.CSSProperties = {  padding: 1,  WebkitMask:    "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",  WebkitMaskComposite: "xor",  maskComposite: "exclude",}; type PointerProjection = {  x: number;  y: number;  width: number;  height: number;  distance: number;}; type PointerState = {  ownerId: string | null;  projections: Record<string, PointerProjection>;} | null; function distanceToRect(x: number, y: number, rect: DOMRect) {  const dx =    x < rect.left ? rect.left - x : x > rect.right ? x - rect.right : 0;  const dy =    y < rect.top ? rect.top - y : y > rect.bottom ? y - rect.bottom : 0;   return Math.sqrt(dx * dx + dy * dy);} function isNearBorder(projection: PointerProjection | undefined) {  if (!projection) return false;  const { height, width, x, y } = projection;  const isWithinHorizontalRange =    x >= -edgeActivationDistance && x <= width + edgeActivationDistance;  const isWithinVerticalRange =    y >= -edgeActivationDistance && y <= height + edgeActivationDistance;   return (    (isWithinHorizontalRange && Math.abs(y) <= edgeActivationDistance) ||    (isWithinHorizontalRange &&      Math.abs(y - height) <= edgeActivationDistance) ||    (isWithinVerticalRange && Math.abs(x) <= edgeActivationDistance) ||    (isWithinVerticalRange && Math.abs(x - width) <= edgeActivationDistance)  );} function borderBeamStyle(  projection: PointerProjection | undefined,): React.CSSProperties {  return {    ...borderBeamMaskStyle,    background:      projection && isNearBorder(projection)        ? `radial-gradient(92px circle at ${projection.x}px ${projection.y}px, ${borderBeamColor}, transparent 68%)`        : "transparent",  };} function projectedPointerState(  event: React.PointerEvent<HTMLElement>,  elements: Map<string, HTMLElement>,): PointerState {  const ownerElement =    event.target instanceof Element      ? event.target.closest<HTMLElement>("[data-magic-bento-item]")      : null;  const ownerId =    ownerElement && event.currentTarget.contains(ownerElement)      ? (ownerElement.dataset.magicBentoItem ?? null)      : null;  const projections: Record<string, PointerProjection> = {};   for (const [id, element] of elements) {    const rect = element.getBoundingClientRect();     projections[id] = {      distance: distanceToRect(event.clientX, event.clientY, rect),      height: rect.height,      width: rect.width,      x: event.clientX - rect.left,      y: event.clientY - rect.top,    };  }   if (!Object.keys(projections).length) return null;   return { ownerId, projections };} export function MagicBentoGrid() {  const cardRefs = React.useRef(new Map<string, HTMLElement>());  const [pointer, setPointer] = React.useState<PointerState>(null);  const [selectedId, setSelectedId] = React.useState(bentoItems[0].id);   const setCardRef = React.useCallback(    (id: string, element: HTMLElement | null) => {      if (element) {        cardRefs.current.set(id, element);        return;      }       cardRefs.current.delete(id);    },    [],  );   const handlePointerMove = React.useCallback(    (event: React.PointerEvent<HTMLDivElement>) => {      setPointer(projectedPointerState(event, cardRefs.current));    },    [],  );   return (    <div className="rounded-card border border-border bg-background p-[48px] shadow-soft">      <div        aria-label="Magic bento cards"        className="-m-1 grid grid-cols-1 gap-0 md:grid-cols-2 lg:grid-cols-3"        onPointerLeave={() => setPointer(null)}        onPointerMove={handlePointerMove}        role="group"      >        {bentoItems.map((item) => {          const projection = pointer?.projections[item.id];          const isLit = Boolean(            projection && projection.distance <= glowRadius,          );          const isSelected = selectedId === item.id;          const glowStyle: React.CSSProperties = {            background: isLit              ? `radial-gradient(${glowRadius}px circle at ${projection!.x}px ${projection!.y}px, ${hoverGlowColor}, transparent 68%)`              : "transparent",          };          const beamStyle = borderBeamStyle(projection);           return (            <button              aria-pressed={isSelected}              className="group h-full min-w-0 rounded-card p-1 text-left outline-none"              data-magic-bento-item={item.id}              key={item.id}              onClick={() => setSelectedId(item.id)}              type="button"            >              <span                className="relative flex h-full min-h-[132px] flex-col overflow-hidden rounded-card border border-border bg-card p-4 transition-colors group-focus-visible:ring-2 group-focus-visible:ring-ring"                data-magic-bento-card=""                ref={(element) => setCardRef(item.id, element)}              >                <span                  aria-hidden="true"                  className="pointer-events-none absolute inset-0 transition-opacity duration-200"                  data-magic-bento-glow=""                  style={glowStyle}                />                <span                  aria-hidden="true"                  className="pointer-events-none absolute inset-0 rounded-card transition-opacity duration-200"                  data-magic-bento-border-beam=""                  style={beamStyle}                />                <span className="relative z-10 flex h-full flex-1 flex-col justify-between gap-8">                  <span className="flex items-center justify-between gap-3">                    <span className="text-sm font-semibold text-foreground">                      {item.title}                    </span>                    <span className="font-mono text-xs text-muted-foreground">                      {item.value}                    </span>                  </span>                  <span className="text-sm leading-6 text-muted-foreground">                    {item.body}                  </span>                  <span className="text-xs font-medium text-primary">                    {isSelected ? "Selected" : "Inspect"}                  </span>                </span>              </span>            </button>          );        })}      </div>    </div>  );} export default MagicBentoGrid;

Agent Prompt

Agent Prompt

Build a Magic Bento interaction inspired by https://reactbits.dev/components/magic-bento, but implement the simplified Lab version with only React, browser pointer events, inline radial gradients, and existing Tailwind utility classes. Do not install animation libraries, do not add Tailwind plugins, and do not change Tailwind config. Deliver one self-contained React component, a small preview, and localized Markdown content. The interaction should show a compact bento grid where pointer movement creates a local card glow, clicking a card pins the selected state, and the visual design remains quiet enough for an engineering blog. Use semantic buttons, `aria-pressed`, theme tokens, and a mobile-safe grid. Acceptance: the grid fits mobile and desktop, the glow follows the hovered card only, the selected state is visible without relying only on color, the example source is the same component used by the demo, and the implementation stays understandable without external animation dependencies.