actionsclient
button
a raised face sitting in a bezel. the bezel stays put, the face lifts when you hover and sinks when you press.
installation
new to blurrow? this sets up your project in one go: the package, the styles and the font. already done it? skip straight to usage. the installation page has the details.
bash
npx @blurrow/ui init›rather own the code? copy the source instead
let the cli copy it, along with anything it needs:
bash
npx @blurrow/ui add buttonor by hand: drop this file into components/ui and import from there.
components/ui/Button.tsxtsx
1"use client";2
3import React, { useState, useRef, useCallback } from "react";4
5type ButtonVariant = "primary" | "secondary" | "ghost" | "outline";6type ButtonSize = "sm" | "md" | "lg" | "icon";7
8interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {9 variant?: ButtonVariant;10 size?: ButtonSize;11 children: React.ReactNode;12 fullWidth?: boolean;13 /** Surface the button sits on. On dark cards, secondary/outline switch to a14 dark face so they don't glare against the card. */15 tone?: "light" | "dark";16 /** Equal width and height, for icon-only layered buttons. */17 square?: boolean;18}19
20/* Layered buttons are a raised face seated in a machined bezel. The bezel21 never moves; the face lifts on hover and sinks flush when pressed. */22const shellConfig: Record<23 string,24 {25 bezel: string;26 bezelShadow: string;27 face: string;28 faceShadow: string;29 faceHoverShadow: string;30 facePressedShadow: string;31 text: string;32 textShadow: string;33 fill: string;34 }35> = {36 primary: {37 bezel: "var(--bezel-dark)",38 bezelShadow: "var(--bezel-dark-shadow)",39 face: "var(--face-dark)",40 faceShadow: "var(--face-dark-shadow)",41 faceHoverShadow:42 "0 0 3px rgba(0,0,0,0.35), 0 2px 3px rgba(0,0,0,0.45), 0 4px 8px -2px rgba(0,0,0,0.35), inset 0 2px 3px -2px rgba(255,255,255,0.135), inset 0 -2px 3px -2px rgba(0,0,0,0.25)",43 facePressedShadow: "var(--face-dark-pressed)",44 text: "text-white",45 textShadow: "0 -1px 0 rgba(0,0,0,0.45)",46 fill: "bg-white/[0.05]",47 },48 secondary: {49 bezel: "var(--bezel-light)",50 bezelShadow: "var(--bezel-light-shadow)",51 face: "var(--face-light)",52 faceShadow: "var(--face-light-shadow)",53 faceHoverShadow:54 "0 0 3px rgba(17,17,17,0.136), 0 2px 3px rgba(17,17,17,0.1), 0 4px 8px -2px rgba(17,17,17,0.08), inset 0 2px 3px -2px rgba(255,255,255,1), inset 0 -2px 3px -2px rgba(17,17,17,0.035)",55 facePressedShadow: "var(--face-light-pressed)",56 text: "text-neutral-800",57 textShadow: "0 1px 0 rgba(255,255,255,0.9)",58 fill: "bg-neutral-900/[0.025]",59 },60 outline: {61 bezel: "linear-gradient(180deg, rgba(255,255,255,0.5) 0%, rgba(17,17,17,0.04) 100%)",62 bezelShadow:63 "0 0 0 0.5px rgba(17,17,17,0.048), inset 0 2px 3px -2px rgba(255,255,255,0.45)",64 face: "var(--face-light)",65 faceShadow:66 "0 0 3px rgba(17,17,17,0.102), 0 1px 1px rgba(17,17,17,0.06), inset 0 2px 3px -2px rgba(255,255,255,1)",67 faceHoverShadow:68 "0 0 3px rgba(17,17,17,0.136), 0 1px 2px rgba(17,17,17,0.08), 0 3px 6px -2px rgba(17,17,17,0.06), inset 0 2px 3px -2px rgba(255,255,255,1)",69 facePressedShadow: "var(--face-light-pressed)",70 text: "text-neutral-700",71 textShadow: "0 1px 0 rgba(255,255,255,0.9)",72 fill: "bg-neutral-900/[0.025]",73 },74};75
76/* Secondary on a dark surface: a raised face a few steps lighter than the77 card — present, but in the same family of tones. */78const darkSecondary = {79 bezel: "linear-gradient(180deg, #2a2a2a 0%, #161616 100%)",80 bezelShadow:81 "0 0 0 0.5px rgba(0,0,0,0.3), 0 1px 2px rgba(0,0,0,0.35), inset 0 2px 3px -2px rgba(255,255,255,0.053)",82 face: "linear-gradient(180deg, #353535 0%, #2a2a2a 55%, #242424 100%)",83 faceShadow:84 "0 0 3px rgba(0,0,0,0.35), 0 1px 1.5px rgba(0,0,0,0.4), inset 0 2px 3px -2px rgba(255,255,255,0.09), inset 0 -2px 3px -2px rgba(0,0,0,0.2)",85 faceHoverShadow:86 "0 0 3px rgba(0,0,0,0.35), 0 2px 3px rgba(0,0,0,0.45), 0 4px 8px -2px rgba(0,0,0,0.35), inset 0 2px 3px -2px rgba(255,255,255,0.12), inset 0 -2px 3px -2px rgba(0,0,0,0.2)",87 facePressedShadow: "var(--face-dark-pressed)",88 text: "text-neutral-100",89 textShadow: "0 -1px 0 rgba(0,0,0,0.4)",90 fill: "bg-white/[0.04]",91};92
93/* Flat buttons (ghost, icon) are a single face without a bezel. */94const flatConfig: Record<95 ButtonVariant,96 {97 bg: string;98 background?: string;99 fill: string;100 shadow: string;101 hoverShadow: string;102 pressedShadow: string;103 }104> = {105 primary: {106 bg: "text-white",107 background: "var(--face-dark)",108 fill: "bg-white/[0.08]",109 shadow: "var(--face-dark-shadow)",110 hoverShadow:111 "0 0 3px rgba(0,0,0,0.35), 0 2px 4px rgba(0,0,0,0.4), 0 6px 14px -4px rgba(0,0,0,0.35), inset 0 2px 3px -2px rgba(255,255,255,0.135)",112 pressedShadow: "var(--face-dark-pressed)",113 },114 secondary: {115 bg: "text-neutral-800",116 background: "var(--face-light)",117 fill: "bg-neutral-900/[0.035]",118 shadow: "var(--face-light-shadow)",119 hoverShadow:120 "0 0 3px rgba(17,17,17,0.136), 0 2px 4px rgba(17,17,17,0.08), 0 6px 14px -4px rgba(17,17,17,0.1), inset 0 2px 3px -2px rgba(255,255,255,1)",121 pressedShadow: "var(--face-light-pressed)",122 },123 ghost: {124 bg: "bg-transparent text-neutral-500 hover:text-neutral-800",125 fill: "bg-neutral-900/[0.05]",126 shadow: "none",127 hoverShadow: "inset 0 0 2px rgba(17,17,17,0.09)",128 pressedShadow: "inset 0 1px 2px rgba(17,17,17,0.08)",129 },130 outline: {131 bg: "bg-transparent text-neutral-700",132 fill: "bg-neutral-900/[0.035]",133 shadow: "inset 0 0 2px rgba(17,17,17,0.15)",134 hoverShadow:135 "inset 0 0 2px rgba(17,17,17,0.24), 0 1px 2px rgba(17,17,17,0.05)",136 pressedShadow:137 "inset 0 0 2px rgba(17,17,17,0.24), inset 0 1px 2px rgba(17,17,17,0.06)",138 },139};140
141/* Bezel radius minus bezel padding = face radius, so the curves stay concentric. */142const shellRadius: Record<ButtonSize, string> = {143 sm: "rounded-[12px] p-[3px]",144 md: "rounded-[13px] p-[3px]",145 lg: "rounded-[15px] p-[3.5px]",146 icon: "",147};148
149const faceRadius: Record<ButtonSize, string> = {150 sm: "rounded-[9px]",151 md: "rounded-[10px]",152 lg: "rounded-[11.5px]",153 icon: "",154};155
156const innerPadding: Record<ButtonSize, string> = {157 sm: "px-4 py-2 text-[13px]",158 md: "px-5 py-2.5 text-sm",159 lg: "px-7 py-3 text-[15px]",160 icon: "w-7 h-7 p-0 text-[13px]",161};162
163const squarePadding: Record<ButtonSize, string> = {164 sm: "w-9 h-9 p-0 text-[13px]",165 md: "w-10 h-10 p-0 text-sm",166 lg: "w-12 h-12 p-0 text-[15px]",167 icon: "",168};169
170const flatSizeStyles: Record<ButtonSize, string> = {171 sm: "px-4 py-2.5 text-[13px] rounded-[10px]",172 md: "px-5 py-3 text-sm rounded-[10px]",173 lg: "px-7 py-3.5 text-[15px] rounded-xl",174 icon: "w-8 h-8 p-0 rounded-[10px] text-[13px]",175};176
177export function Button({178 variant = "primary",179 size = "md",180 fullWidth = false,181 tone = "light",182 square = false,183 className = "",184 children,185 disabled,186 onClick,187 onPointerDown,188 onPointerUp,189 onPointerLeave,190 onPointerCancel,191 onPointerEnter,192 style,193 ...rest194}: ButtonProps) {195 const [pressed, setPressed] = useState(false);196 const [hovered, setHovered] = useState(false);197 const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);198
199 /* The press state is the button's own; handlers passed in run alongside200 it rather than replacing it */201 const handlePointerDown = useCallback(202 (e: React.PointerEvent<HTMLButtonElement>) => {203 if (timerRef.current) clearTimeout(timerRef.current);204 setPressed(true);205 onPointerDown?.(e);206 },207 [onPointerDown],208 );209
210 const handlePointerUp = useCallback(211 (e: React.PointerEvent<HTMLButtonElement>) => {212 timerRef.current = setTimeout(() => setPressed(false), 150);213 onPointerUp?.(e);214 },215 [onPointerUp],216 );217
218 const handlePointerCancel = useCallback(219 (e: React.PointerEvent<HTMLButtonElement>) => {220 timerRef.current = setTimeout(() => setPressed(false), 150);221 onPointerCancel?.(e);222 },223 [onPointerCancel],224 );225
226 const handlePointerLeave = useCallback(227 (e: React.PointerEvent<HTMLButtonElement>) => {228 setHovered(false);229 if (timerRef.current) clearTimeout(timerRef.current);230 timerRef.current = setTimeout(() => setPressed(false), 150);231 onPointerLeave?.(e);232 },233 [onPointerLeave],234 );235
236 const layered = variant !== "ghost" && size !== "icon";237
238 const handlers = {239 onClick,240 onPointerDown: handlePointerDown,241 onPointerUp: handlePointerUp,242 onPointerLeave: handlePointerLeave,243 onPointerCancel: handlePointerCancel,244 onPointerEnter: (e: React.PointerEvent<HTMLButtonElement>) => {245 setHovered(true);246 onPointerEnter?.(e);247 },248 };249
250 if (layered) {251 const cfg =252 tone === "dark" && (variant === "secondary" || variant === "outline")253 ? darkSecondary254 : shellConfig[variant];255 const faceShadow = pressed256 ? cfg.facePressedShadow257 : hovered258 ? cfg.faceHoverShadow259 : cfg.faceShadow;260
261 return (262 <button263 data-btn264 className={[265 "group/btn relative inline-flex cursor-pointer select-none whitespace-nowrap",266 shellRadius[size],267 "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900",268 "disabled:opacity-40 disabled:pointer-events-none",269 fullWidth ? "w-full" : "",270 className,271 ]272 .filter(Boolean)273 .join(" ")}274 style={{275 background: cfg.bezel,276 boxShadow: cfg.bezelShadow,277 ...style,278 }}279 disabled={disabled}280 {...handlers}281 {...rest}282 >283 <span284 className={[285 "btn-face relative overflow-hidden flex items-center justify-center gap-1.5 font-medium",286 faceRadius[size],287 cfg.text,288 square ? squarePadding[size] : ["w-full", innerPadding[size]].join(" "),289 ].join(" ")}290 style={{291 background: cfg.face,292 boxShadow: faceShadow,293 textShadow: cfg.textShadow,294 transform: pressed295 ? "translateY(0.5px) scale(0.985)"296 : hovered297 ? "translateY(-0.5px)"298 : "none",299 transition:300 "transform 240ms var(--ease-spring), box-shadow 200ms ease-out",301 }}302 >303 <span304 aria-hidden="true"305 className={[306 "pointer-events-none absolute inset-0 origin-bottom transition-transform duration-300 ease-out",307 pressed308 ? "scale-y-100"309 : "scale-y-0 group-hover/btn:scale-y-100",310 cfg.fill,311 ].join(" ")}312 />313 <span className="btn-icon-nudge relative z-10 inline-flex items-center gap-1.5">314 {children}315 </span>316 </span>317 </button>318 );319 }320
321 const cfg = flatConfig[variant];322 const currentShadow = pressed323 ? cfg.pressedShadow324 : hovered325 ? cfg.hoverShadow326 : cfg.shadow;327
328 return (329 <button330 data-btn331 className={[332 "btn-face group/btn relative overflow-hidden inline-flex items-center justify-center gap-1.5 font-medium cursor-pointer select-none whitespace-nowrap",333 "transition-colors duration-200 ease-out",334 "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900",335 "disabled:opacity-40 disabled:pointer-events-none",336 cfg.bg,337 flatSizeStyles[size],338 fullWidth ? "w-full" : "",339 className,340 ]341 .filter(Boolean)342 .join(" ")}343 style={{344 background: cfg.background,345 boxShadow: currentShadow,346 transform: pressed347 ? "translateY(0.5px) scale(0.97)"348 : hovered && cfg.background349 ? "translateY(-0.5px)"350 : "none",351 transition:352 "transform 240ms var(--ease-spring), box-shadow 200ms ease-out, color 200ms ease-out",353 ...style,354 }}355 disabled={disabled}356 {...handlers}357 {...rest}358 >359 <span360 aria-hidden="true"361 className={[362 "pointer-events-none absolute inset-0 origin-bottom transition-transform duration-300 ease-out",363 pressed ? "scale-y-100" : "scale-y-0 group-hover/btn:scale-y-100",364 cfg.fill,365 ].join(" ")}366 />367 <span className="relative z-10 inline-flex items-center gap-1.5">368 {children}369 </span>370 </button>371 );372}usage
tsx
import { Button } from "@blurrow/ui";tsx
<Button variant="primary">get started</Button>examples
variants
primary and secondary are layered; outline is a lighter bezel; ghost is a single flat face.
sizes
with icon
a trailing icon leans toward where the button is taking you.
icon only
use size="icon" for a bezel-less key, or square for a layered one.
on a dark surface
tone="dark" swaps secondary and outline to a dark face so they don't glare.
props
straight from the source, so they're always up to date.
propsts
type ButtonVariant = "primary" | "secondary" | "ghost" | "outline";
type ButtonSize = "sm" | "md" | "lg" | "icon";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { variant?: ButtonVariant; size?: ButtonSize; children: React.ReactNode; fullWidth?: boolean; /** Surface the button sits on. On dark cards, secondary/outline switch to a dark face so they don't glare against the card. */ tone?: "light" | "dark"; /** Equal width and height, for icon-only layered buttons. */ square?: boolean;}