Skip to content

Hairline Tile Button

A button built like a bento tile: a tonal step on hover, a hairline that catches a light under the pointer, keycaps, and nothing that lifts.

FreeBento Boxminimal
Category
Components
Added
2026-09-26
Deps
none
Updated
2026-09-26
Preview

Deploy · 01

Ship the first version today.

Props

Keycaps
No runtime dependencies
"use client";

import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactNode } from "react";

/**
 * HairlineTileButton — a button built like a bento tile: a flat tonal
 * surface, a 1px hairline, no shadow, and nothing that lifts.
 *
 * Hover steps the surface one luminance level up (tile to raised) and the
 * hairline brightens, but most of all the hairline catches a light that sits
 * under the pointer and slides along the border as you move, the "alive"
 * edge of the best dark bento grids. The content answers too: the arrow
 * travels, or the keycaps light. Press steps the surface down a level.
 *
 * Two variants: "tile" (the quiet default) and "accent", the one filled
 * colour a bento screen allows. Optional mono metadata above the label
 * (a version, a status), optional keycaps for a shortcut hint, optional
 * leading icon. A link when you pass href (sanitised), otherwise a button.
 *
 * Radius 14 is concentric with the kit's 20px tiles at a 6px inset, so the
 * button sits in a tile's corner without a mismatched curve. Mono type comes
 * from --font-mono (or the site's --font-geist-mono) with a system fallback.
 *
 * Needs Tailwind v4 (or v3.4+). No dependencies beyond React.
 */

type Variant = "tile" | "accent";
type Size = "md" | "lg";

const MONO = "var(--font-mono, var(--font-geist-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace))";

/** Allow relative paths, fragments, http(s), mailto and tel; anything else becomes "#". */
function safeHref(href: string): string {
  const v = href.replace(/[\t\n\r]/g, "").trim();
  if (v.indexOf(String.fromCharCode(92)) !== -1) return "#";
  if (/^(https?:|mailto:|tel:)/i.test(v)) return v;
  if (/^[/#?]/.test(v) && !/^\/\//.test(v)) return v;
  return "#";
}

/** "#rrggbb" if valid, otherwise the fallback. Never interpolated unchecked. */
function safeHex(hex: string | undefined, fallback: string): string {
  return /^#[0-9a-f]{6}$/i.test(hex || "") ? (hex as string) : fallback;
}

/** Relative luminance, to pick dark or light text on the accent. */
function isLight(hex: string): boolean {
  const n = parseInt(hex.slice(1), 16);
  const c = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => {
    const s = v / 255;
    return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
  });
  return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2] > 0.18;
}

export function HairlineTileButton({
  children,
  href,
  onClick,
  type = "button",
  variant = "tile",
  size = "md",
  meta,
  kbd,
  icon,
  arrow = true,
  accent = "#ff5a1f",
  disabled = false,
  className = "",
}: {
  children: ReactNode;
  /** Renders a link instead of a button. Sanitised. */
  href?: string;
  onClick?: () => void;
  type?: "button" | "submit";
  variant?: Variant;
  size?: Size;
  /** Mono metadata above the label, e.g. "v4.2" or "Status". */
  meta?: string;
  /** Keycaps for a shortcut hint, e.g. ["⌘", "K"]. */
  kbd?: string[];
  icon?: ReactNode;
  /** A trailing arrow that travels on hover (hidden when kbd is set). */
  arrow?: boolean;
  /** The one accent colour, "#rrggbb". */
  accent?: string;
  disabled?: boolean;
  className?: string;
}) {
  const a = safeHex(accent, "#ff5a1f");
  const filled = variant === "accent";
  const ink = filled ? (isLight(a) ? "#0b0b0c" : "#ffffff") : "#f4f4f5";

  // The hairline's light sits under the pointer, in the button's own layout
  // pixels even inside a transform-scaled container.
  function onMove(e: ReactPointerEvent<HTMLElement>) {
    const el = e.currentTarget;
    const r = el.getBoundingClientRect();
    const k = el.offsetWidth / (r.width || 1);
    el.style.setProperty("--htb-x", ((e.clientX - r.left) * k).toFixed(1) + "px");
    el.style.setProperty("--htb-y", ((e.clientY - r.top) * k).toFixed(1) + "px");
  }

  const style = {
    color: ink,
    "--htb-bg": filled ? a : "#0f1012",
    "--htb-hover": filled ? a : "#16171a",
    "--htb-press": filled ? a : "#0c0d0f",
    "--htb-line": filled ? "rgba(255,255,255,0.22)" : "rgba(255,255,255,0.08)",
    "--htb-line-hover": filled ? "rgba(255,255,255,0.4)" : "rgba(255,255,255,0.16)",
    "--htb-x": "50%",
    "--htb-y": "0px",
  } as CSSProperties;

  const cls =
    "group/htb relative isolate inline-flex select-none items-center gap-3 overflow-hidden rounded-[14px] text-left outline-none " +
    "bg-[var(--htb-bg)] shadow-[inset_0_0_0_1px_var(--htb-line)] transition-[background-color,box-shadow,filter] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] " +
    "hover:bg-[var(--htb-hover)] hover:shadow-[inset_0_0_0_1px_var(--htb-line-hover)] active:bg-[var(--htb-press)] " +
    (filled ? "hover:brightness-[1.08] active:brightness-[0.94] " : "") +
    "focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-[#08090a] " +
    (size === "lg" ? "min-h-14 px-5 py-3 text-[15px] " : "min-h-11 px-4 py-2 text-[14px] ") +
    (disabled ? "pointer-events-none opacity-40 " : "cursor-pointer ") +
    className;

  const inner = (
    <>
      {/* The hairline's light: a ring that is bright only near the pointer. */}
      <span
        aria-hidden
        className="pointer-events-none absolute inset-0 rounded-[14px] opacity-0 transition-opacity duration-200 group-hover/htb:opacity-100"
        style={{
          padding: 1,
          background:
            "radial-gradient(90px circle at var(--htb-x) var(--htb-y), " + (filled ? "rgba(255,255,255,0.9)" : "rgba(255,255,255,0.55)") + ", rgba(255,255,255,0) 70%)",
          WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
          WebkitMaskComposite: "xor",
          mask: "linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0)",
        }}
      />
      {icon && (
        <span aria-hidden className="relative flex h-5 w-5 shrink-0 items-center justify-center [&>svg]:h-[18px] [&>svg]:w-[18px]">
          {icon}
        </span>
      )}
      <span className="relative flex min-w-0 flex-col">
        {meta && (
          <span
            className="text-[11px] uppercase leading-[14px] tracking-[0.12em]"
            style={{ fontFamily: MONO, opacity: filled ? 0.7 : 0.5 }}
          >
            {meta}
          </span>
        )}
        <span className="truncate font-medium tracking-[-0.01em]">{children}</span>
      </span>
      {kbd && kbd.length > 0 ? (
        <span aria-hidden className="relative ml-auto flex shrink-0 gap-1 pl-2">
          {kbd.map((k, i) => (
            <kbd
              key={i}
              className="flex h-6 min-w-6 items-center justify-center rounded-[6px] px-1.5 text-[11px] transition-colors duration-200 group-hover/htb:text-white"
              style={{
                fontFamily: MONO,
                color: filled ? ink : "rgba(255,255,255,0.6)",
                background: filled ? "rgba(0,0,0,0.14)" : "#16171a",
                boxShadow: "inset 0 -1px 0 rgba(0,0,0,0.35), inset 0 0 0 1px rgba(255,255,255," + (filled ? 0.18 : 0.1) + ")",
              }}
            >
              {k}
            </kbd>
          ))}
        </span>
      ) : arrow ? (
        <span
          aria-hidden
          className="relative ml-auto inline-block pl-2 transition-transform duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] group-hover/htb:translate-x-1"
          style={{ opacity: filled ? 0.9 : 0.6 }}
        >
          →
        </span>
      ) : null}
    </>
  );

  const ringColor = { "--tw-ring-color": filled ? "#ffffff" : a } as CSSProperties;

  if (href && !disabled) {
    return (
      <a href={safeHref(href)} onClick={onClick} onPointerMove={onMove} className={cls} style={{ ...style, ...ringColor }}>
        {inner}
      </a>
    );
  }
  return (
    <button type={type} onClick={onClick} onPointerMove={onMove} disabled={disabled} className={cls} style={{ ...style, ...ringColor }}>
      {inner}
    </button>
  );
}
Notes

About this component

A bento grid has no drop shadows and nothing lifts on hover, so its buttons cannot borrow the usual tricks. This one is built like a small tile. Hover steps the surface up one luminance level and brightens the one-pixel hairline, and the hairline catches a light that sits under the pointer and slides along the border as you move, the living edge the best dark grids use. The content answers rather than the box: the arrow travels, the keycaps light up. Press steps the surface down. The accent variant is the one filled colour a bento screen allows, with its text colour picked from the accent's luminance. Add a mono metadata line for a version or status, keycaps for a shortcut hint, or a leading icon, and pass href to make it a link. Its 14px radius is concentric with the kit's 20px tiles, so it sits in a tile's corner without a clashing curve.

Curator’s note

Part of the Bento Box kit, drop 2, phase 4. Free: the kit's primary control, CSS and one pointer handler.

Related

Pairs well with

Matched on shared tags and category — the entries most likely to be used alongside this one.

New

Spec Numeral Tile

text effects

A big bold numeral in a spec-sheet voice whose digits roll into place like an odometer, set in a hairline bento tile.

numeralcount upodometer
NewPro

A complete launch page from the Bento Box kit: keynote figures, product captures, proof, pricing and a footer set as tiles, broken once by a full-bleed race between two builds.

templatelanding pagelaunch page

A share icon that expands into a row of social options with a copy-link state.

sharebuttonexpand