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.
- Category
- Components
- Added
- 2026-09-26
- Deps
- none
- Updated
- 2026-09-26
Deploy · 01
Ship the first version today.
Props
"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>
);
}
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.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Spec Numeral Tile
text effectsA big bold numeral in a spec-sheet voice whose digits roll into place like an odometer, set in a hairline bento tile.
Bento Launch Template
templatesA 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.
Share Button Expand
componentsA share icon that expands into a row of social options with a copy-link state.