{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"offset-target-button","type":"registry:component","title":"Offset Target Button","description":"A button whose label plate hangs off its hairline target along the grid's axis, reaches further and lights its bracket on hover or focus, and seats into the target in 120ms when you press it.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/offset-target-button.tsx","content":"\"use client\";\n\nimport { useState, useSyncExternalStore } from \"react\";\nimport type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, ReactNode } from \"react\";\n\n/**\n * OffsetTargetButton — a button whose label plate hangs off its target, along the grid's axis, and seats into it when pressed.\n *\n * The target is the socket, a hairline rectangle where the button is. The\n * label plate hangs off its leading edge by 8px; hover or focus and it\n * reaches further and the bracket lights, press and it seats in 120ms. The\n * socket never moves and the whole label takes the click. Set breaks when\n * the button is the third of a run.\n *\n * Part of the Broken Grid kit: a Klein-blue field #2416f0, paper plates\n * #f1f0e8, ink #0a0a1f, and acid #e2ff3a only where a rule fires. One rule,\n * \"The Third\": every third module in a run overshoots its leading edge by two\n * gutters (overlapping its neighbour by one) and wears the acid bracket; the\n * ends never break. Radius 0, no shadows; reflows in 380ms, no bounce. Big\n * Shoulders through var(--font-big-shoulders) for display over Geist.\n * Respects prefers-reduced-motion. No dependencies beyond React. Paste it as\n * its own file: it repeats the kit's small helpers, which would clash in one\n * module. Load Big Shoulders with next/font (variable:\n * \"--font-big-shoulders\") on a parent, or from Google Fonts or @fontsource.\n */\n\nconst DISPLAY = 'var(--font-big-shoulders, \"Big Shoulders\", \"Big Shoulders Display\", \"Arial Narrow\", \"Roboto Condensed\", sans-serif)';\n\n/**\n * A Klein-blue field, paper plates, blue-black ink, and acid for a rule firing.\n * Secondary and tertiary text clear 4.5:1 on their grounds (ink2 7.4, ink3 5.0\n * on paper; onField2 5.9, onField3 4.8 on the field); `edgeOnPaper` is the 3:1\n * boundary for inputs.\n */\nconst FIELD = {\n  field: \"#2416f0\",\n  raised: \"#3a2ff5\",\n  pressed: \"#1a0ec4\",\n  paper: \"#f1f0e8\",\n  ink: \"#0a0a1f\",\n  ink2: \"rgba(10,10,31,0.72)\",\n  ink3: \"rgba(10,10,31,0.6)\",\n  onField2: \"rgba(241,240,232,0.86)\",\n  onField3: \"rgba(241,240,232,0.76)\",\n  lineOnField: \"rgba(241,240,232,0.22)\",\n  lineOnPaper: \"rgba(10,10,31,0.14)\",\n  edgeOnPaper: \"rgba(10,10,31,0.5)\",\n  edgeOnField: \"rgba(241,240,232,0.6)\",\n  acid: \"#e2ff3a\",\n} as const;\n\n/** The mark (bracket, tag, focus ring) for a ground: acid on the field, ink on paper. */\nfunction bgMark(ground: \"field\" | \"paper\"): CSSProperties {\n  const c = ground === \"field\" ? FIELD.acid : FIELD.ink;\n  return { [\"--bg-mark\" as string]: c, [\"--bg-ring\" as string]: c } as CSSProperties;\n}\n\n/** Layout changes: 380ms, no stagger, no bounce. Role hand-offs take 240ms. */\nconst BG_EASE = \"cubic-bezier(.2,.8,.2,1)\";\n\nconst BG_HANDOFF_MS = 240;\n\n/** Links: strip tabs and newlines, refuse backslashes, allow http(s), mailto, tel and same-site paths. */\nfunction safeHref(raw: string): string {\n  const v = raw.replace(/[\\t\\n\\r]/g, \"\").trim();\n  if (!v || v.includes(\"\\\\\")) return \"#\";\n  if (/^(https?:|mailto:|tel:)/i.test(v)) return v;\n  if (/^[/#?]/.test(v) && !/^\\/\\//.test(v)) return v;\n  return \"#\";\n}\n\nfunction subscribeMotion(cb: () => void) {\n  const m = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  m.addEventListener(\"change\", cb);\n  return () => m.removeEventListener(\"change\", cb);\n}\n\n/** True when the visitor asked for reduced motion. False on the server. */\nfunction useReducedMotion(): boolean {\n  return useSyncExternalStore(\n    subscribeMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n// Each part carries only the CSS it needs, so any one of them works on its own.\n// revert-layer keeps an element's own corners when a host page's focus rule rounds or flattens them.\nconst FOCUS_CSS = \".bg-scope :focus-visible,.bg-scope:focus-visible{outline:2px solid var(--bg-ring,#e2ff3a)!important;outline-offset:3px;border-radius:revert-layer}\";\n\n/**\n * The bracket a module wears when the rule breaks it: an L of 2px, one gutter\n * long, 6px outside its leading corner, so it reads against the ground. Its\n * colour is --bg-mark (acid on the field, ink on paper; see bgMark).\n */\nfunction BgBracket({ on, size = 24, className = \"\" }: { on: boolean; size?: number; className?: string }) {\n  const reduced = useReducedMotion();\n  return (\n    <span\n      aria-hidden\n      className={\"pointer-events-none absolute left-0 top-0 block \" + className}\n      style={{\n        width: size,\n        height: size,\n        borderTop: `2px solid var(--bg-mark, ${FIELD.acid})`,\n        borderLeft: `2px solid var(--bg-mark, ${FIELD.acid})`,\n        transform: \"translate(-6px, -6px)\",\n        opacity: on ? 1 : 0,\n        transition: reduced ? \"none\" : `opacity ${BG_HANDOFF_MS}ms ${BG_EASE}`,\n      }}\n    />\n  );\n}\n\nconst BG_BUTTON_INK = {\n  paper: { label: FIELD.paper, text: FIELD.ink, socket: \"rgba(241,240,232,0.42)\", socketOn: \"rgba(241,240,232,0.7)\", ground: \"field\" },\n  ink: { label: FIELD.ink, text: FIELD.paper, socket: \"rgba(10,10,31,0.34)\", socketOn: \"rgba(10,10,31,0.6)\", ground: \"paper\" },\n} as const;\n\nexport type BgButtonTone = keyof typeof BG_BUTTON_INK;\n\n/**\n * OffsetTargetButton — a button whose label plate hangs off its target, along\n * the grid's own axis, and seats into it when pressed.\n *\n * The target is the socket, a hairline rectangle exactly where a button\n * should be. The label plate is the same size and hangs off its leading edge\n * by `hang` (8px, a third of a gutter at 1280): the kit's overshoot at the\n * scale of a control, sideways only, never a diagonal drop shadow. Hover or\n * focus it and it hangs further (14px) and the bracket lights; press and it\n * seats into the socket in 120ms, then hangs again on release (200ms). The\n * socket never moves, and the strip the label hangs over is part of the\n * button, so every pixel of the label is clickable. Set `breaks` when the\n * button is the third of a run, and the bracket stays on. Paper for the\n * field, ink for paper; the focus ring follows. A link when given an href.\n */\nexport function OffsetTargetButton({\n  children,\n  href,\n  onClick,\n  type = \"button\",\n  tone = \"paper\",\n  size = \"lg\",\n  hang = 8,\n  breaks = false,\n  disabled = false,\n  ariaLabel,\n  className = \"\",\n}: {\n  children: ReactNode;\n  href?: string;\n  onClick?: (e: ReactMouseEvent<HTMLElement>) => void;\n  type?: \"button\" | \"submit\";\n  tone?: BgButtonTone;\n  size?: \"lg\" | \"md\" | \"sm\";\n  /** How far the label hangs off its target, in px. */\n  hang?: number;\n  /** The rule fired on this button (the third of a run): keep the bracket on. */\n  breaks?: boolean;\n  disabled?: boolean;\n  ariaLabel?: string;\n  className?: string;\n}) {\n  const ink = BG_BUTTON_INK[tone] ?? BG_BUTTON_INK.paper;\n  const [state, setState] = useState<\"rest\" | \"hover\" | \"down\">(\"rest\");\n  const reduced = useReducedMotion();\n  // Resting, the label hangs by `hang`; hovered or focused it reaches further (1.75x); pressed it seats at 0.\n  const off = state === \"down\" ? 0 : state === \"hover\" ? hang * 1.75 : hang;\n  const pad = size === \"sm\" ? \"h-10 px-4 text-[14px]\" : size === \"md\" ? \"h-12 px-5 text-[16px]\" : \"h-14 px-6 text-[18px]\";\n  const handlers = {\n    \"aria-label\": ariaLabel,\n    className: \"bg-scope group relative inline-flex select-none no-underline outline-none \" + pad + (disabled ? \" pointer-events-none opacity-40 \" : \" \") + className,\n    style: { WebkitTapHighlightColor: \"transparent\", touchAction: \"manipulation\", borderRadius: 0, ...bgMark(ink.ground) } as CSSProperties,\n    onPointerEnter: () => setState(\"hover\"),\n    onPointerLeave: () => setState(\"rest\"),\n    onPointerDown: (e: ReactPointerEvent<HTMLElement>) => {\n      if (e.button === 0) setState(\"down\");\n    },\n    onPointerUp: () => setState(\"hover\"),\n    onPointerCancel: () => setState(\"rest\"),\n    onKeyDown: (e: ReactKeyboardEvent<HTMLElement>) => {\n      if (!e.repeat && (e.key === \"Enter\" || (e.key === \" \" && !href))) setState(\"down\");\n    },\n    onKeyUp: () => setState(\"hover\"),\n    onFocus: () => setState((s) => (s === \"rest\" ? \"hover\" : s)),\n    onBlur: () => setState(\"rest\"),\n  };\n  const body = (\n    <>\n      {/* The socket: the real target, drawn as a hairline. */}\n      <span aria-hidden className=\"pointer-events-none absolute inset-0\" style={{ boxShadow: `inset 0 0 0 1px ${state === \"rest\" ? ink.socket : ink.socketOn}` }} />\n      {/* The strip the label hangs over belongs to the button, so the whole label takes the click. */}\n      <span aria-hidden className=\"absolute inset-y-0 right-full\" style={{ width: hang * 1.75 }} />\n      {/* The label plate, hanging off the socket's leading edge. */}\n      <span\n        className=\"pointer-events-none absolute inset-0 flex items-center justify-center whitespace-nowrap\"\n        style={{\n          background: ink.label,\n          color: ink.text,\n          transform: `translateX(${-off}px)`,\n          transition: reduced ? \"none\" : `transform ${state === \"down\" ? 120 : 200}ms ${BG_EASE}`,\n          fontFamily: DISPLAY,\n          fontWeight: 800,\n          textTransform: \"uppercase\",\n          letterSpacing: \"0.03em\",\n        }}\n      >\n        <BgBracket on={breaks || state !== \"rest\"} size={12} />\n        {children}\n      </span>\n      {/* An invisible copy keeps the button as wide as its label. */}\n      <span aria-hidden className=\"invisible flex items-center whitespace-nowrap\" style={{ fontFamily: DISPLAY, fontWeight: 800, textTransform: \"uppercase\", letterSpacing: \"0.03em\" }}>\n        {children}\n      </span>\n    </>\n  );\n  if (href && !disabled) {\n    return (\n      <>\n        <style>{FOCUS_CSS}</style>\n        <a href={safeHref(href)} onClick={onClick} {...handlers}>\n          {body}\n        </a>\n      </>\n    );\n  }\n  return (\n    <>\n      <style>{FOCUS_CSS}</style>\n      <button type={type} onClick={onClick} disabled={disabled} {...handlers}>\n        {body}\n      </button>\n    </>\n  );\n}\n","type":"registry:component","target":"components/viberdy/offset-target-button.tsx"}],"meta":{"tier":"free","tags":["button","cta","broken grid","offset","press","swiss"],"href":"https://viberdy.dev/library/offset-target-button","aiPrompt":"Build a React + Tailwind button, `OffsetTargetButton({ children, href, onClick, type, tone, size, hang, breaks, disabled, ariaLabel, className })`.\n\nBroken Grid grammar: a visible grid that holds, and one rule that breaks it the same way everywhere. A Klein-blue field #2416f0 as the ground (raised #3a2ff5, pressed #1a0ec4), paper plates #f1f0e8, blue-black ink #0a0a1f; secondary and tertiary text at 72/60% ink on paper and 86/76% paper on the field (all 4.5:1 or better), input boundaries at 50% ink (3:1). The mark means one thing, a rule firing or the current state (the bracket on a breaking module, an index tag, a selected chip): acid #e2ff3a on the field, ink on paper, where acid would vanish; never a wash, a gradient or text on paper. The lattice: twelve columns from a 48rem container (gutter 1.875%, 24px at 1280), four on a phone (8px gutters); the margin is two gutters and 8px (56px at 1280, 24px on a phone), so a breaker in the first column runs into the margin with its bracket still on the page; rows square with the columns; hairlines through the gutter centres (paper at 22% on the field), small crosses where they meet; drawn in full only at the anchors (hero, footer), elsewhere as column ticks. THE RULE, \"The Third\": in every run of modules, module 3, 6, 9... (never the first or the last) overshoots its leading edge by two gutters, one closing the gap and one overlapping its neighbour, rides one layer above it, wears the bracket (a 2px L one gutter long, 6px outside its leading corner, read against the ground) and gets a firmer leading edge (half-strength text colour) so the overlap reads as two plates; its padding grows by the same so its content never moves, and the module it overlaps keeps a gutter of trailing padding clear. Type follows the same law with one column pitch as the unit. The break belongs to the position: reorder a run and the break passes to whichever module is now third; compute it, never place it. Radius 0, no shadows, no grain; depth is overlap and a 1px hairline (inset box-shadow, never a border). Type: Big Shoulders at 800 in capitals through var(--font-big-shoulders, \"Big Shoulders\", ...) for display (up to 164px, leading 0.84-0.86), Geist Mono 11px uppercase +0.08em for labels (never smaller), Geist 16-17px/1.5 for reading. Motion: layouts change in 380ms on cubic-bezier(.2,.8,.2,1), all at once, no stagger, no spring, no overshoot (the layout overshoots, the motion never does); a role hand-off (breaking or letting go) takes 240ms; a drag follows 1:1 and snaps in 320ms; controls press in 120ms, release in 200ms, labels step in 160ms; tags step (steps(4)), never glide; scroll only ever changes a layout at checkpoints (never a continuous scrub); no ambient loops; reduced motion makes every change instant.\n\nThe hit area (a button, or a link with href unless disabled) is the socket: a 1px inset hairline (paper at 42% on the field for the paper tone, ink at 34% for the ink tone; 70% and 60% while hovered), radius 0, sized by an invisible copy of the label, plus a transparent strip left of it as wide as the furthest hang, so the whole label is clickable. Over it, the label plate: a span the same size (paper with ink text, or ink with paper text) in Big Shoulders 800 caps, +0.03em, translated by translateX(-hang) (hang 8px; 1.75x on hover or focus; 0 while pressed) with a transition of 120ms into the press and 200ms out, on cubic-bezier(.2,.8,.2,1), and a 12px bracket on its top-left corner, lit while hovered, focused or pressed, or always when `breaks`. The tone sets the mark and focus-ring colour (acid for paper-on-field, ink for ink-on-paper). Sizes lg 56px, md 48px, sm 40px tall. Enter (and Space on a button) presses; key up and blur release.","license":"https://viberdy.dev/license"}}