{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"pushed-shadow-button","type":"registry:component","title":"Pushed Shadow Button","description":"A neo-brutalist button on a hard 5px shadow: hover lifts it 2px, a press lands it exactly on its shadow. 3px ink everywhere, 100ms linear, no blur.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/pushed-shadow-button.tsx","content":"\"use client\";\n\nimport { useState } from \"react\";\nimport type { CSSProperties, KeyboardEvent, MouseEventHandler, ReactNode } from \"react\";\n\n/**\n * PushedShadowButton — a neo-brutalist button that behaves like a physical\n * object sitting on its own shadow.\n *\n * The shadow is hard (no blur) and its far corner never moves. Point at the\n * button and it lifts 2px toward you while the shadow grows from 5px to 7px;\n * press it and it lands exactly on the shadow (translate 5px, shadow 0).\n * Every move is 100ms linear: no easing, no overshoot, no scale.\n *\n * The press shows for mouse, touch, pen and keyboard alike (Enter and Space\n * hold it down until the key comes up). The border, the shadow and the arrow's\n * stroke are all the same 3px ink, because a brutalist surface has no blur to\n * hide an inconsistent pixel in.\n *\n * Renders a link when given href, otherwise a button. Type reads\n * var(--font-display) first, so a buyer's own next/font grotesque drops in.\n *\n * Needs Tailwind v4 (or v3.4+). No dependencies beyond React.\n */\n\nconst DISPLAY = 'var(--font-display, \"Archivo\", \"Archivo Black\", \"Arial Black\", \"Helvetica Neue\", Arial, sans-serif)';\n\n/** The kit's flat fills. */\nconst FILLS = {\n  yellow: \"#ffd23f\",\n  coral: \"#ff6b6b\",\n  blue: \"#74b9ff\",\n  lime: \"#b4f462\",\n  pink: \"#ff5fa2\",\n  white: \"#ffffff\",\n  cream: \"#fffdf5\",\n  black: \"#000000\",\n} as const;\n\nexport type PushedShadowColor = keyof typeof FILLS;\n\nconst HEX = /^#[0-9a-fA-F]{6}$/;\n\n/** Relative luminance of #rrggbb, 0 to 1. */\nfunction luminance(hex: string) {\n  const c = [1, 3, 5].map((i) => {\n    const v = parseInt(hex.slice(i, i + 2), 16) / 255;\n    return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);\n  });\n  return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];\n}\n\n/** Links: strip tabs and newlines, refuse backslashes, allow http(s), mailto, tel and same-site paths. */\nfunction safeHref(raw: 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\nexport type PushedShadowButtonProps = {\n  children?: ReactNode;\n  /** Renders a link. */\n  href?: string;\n  /** A kit fill, or any #rrggbb. */\n  color?: PushedShadowColor | string;\n  /** Border, shadow and (on light fills) label colour, as #rrggbb. Use a light ink on dark pages. */\n  ink?: string;\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** A 3px-stroke arrow after the label that steps 3px right on hover. */\n  arrow?: boolean;\n  /** Uppercase label. */\n  caps?: boolean;\n  /** Stretch to the container's width. */\n  block?: boolean;\n  /** Shows a stepped progress mark, sets aria-busy and ignores clicks. */\n  busy?: boolean;\n  busyLabel?: string;\n  type?: \"button\" | \"submit\" | \"reset\";\n  disabled?: boolean;\n  onClick?: MouseEventHandler<HTMLElement>;\n  className?: string;\n};\n\nexport function PushedShadowButton({\n  children = \"Start building\",\n  href,\n  color = \"yellow\",\n  ink = \"#000000\",\n  size = \"md\",\n  arrow = false,\n  caps = false,\n  block = false,\n  busy = false,\n  busyLabel = \"Working\",\n  type = \"button\",\n  disabled = false,\n  onClick,\n  className = \"\",\n}: PushedShadowButtonProps) {\n  const [pressed, setPressed] = useState(false);\n  const named = Object.prototype.hasOwnProperty.call(FILLS, color) ? FILLS[color as PushedShadowColor] : null;\n  const fill = named ?? (HEX.test(color) ? color : FILLS.yellow);\n  const line = HEX.test(ink) ? ink : \"#000000\";\n  // Dark fills take a cream label; everything else reads in the ink.\n  const label = luminance(fill) < 0.3 ? (luminance(line) < 0.3 ? \"#fffdf5\" : \"#000000\") : line;\n  const inert = disabled || busy;\n  const down = pressed && !inert;\n\n  const release = () => setPressed(false);\n  const onKeyDown = (e: KeyboardEvent<HTMLElement>) => {\n    if (e.repeat) return;\n    if (e.key === \"Enter\" || (e.key === \" \" && !href)) setPressed(true);\n  };\n\n  const cls =\n    \"group relative inline-flex select-none items-center justify-center gap-2.5 whitespace-nowrap leading-none \" +\n    \"transition-[transform,translate,box-shadow] duration-100 ease-linear motion-reduce:transition-none \" +\n    \"focus-visible:![outline-offset:4px] focus-visible:![outline-style:dashed] focus-visible:![outline-width:3px] focus-visible:![border-radius:0] \" +\n    (size === \"sm\" ? \"h-10 px-4 text-[14px] \" : size === \"lg\" ? \"h-16 px-8 text-[20px] \" : \"h-12 px-6 text-[16px] \") +\n    (caps ? \"uppercase tracking-[0.04em] \" : \"tracking-[-0.005em] \") +\n    (block ? \"w-full \" : \"\") +\n    (disabled\n      ? \"cursor-not-allowed shadow-none \"\n      : busy\n        ? \"cursor-progress shadow-[5px_5px_0_0_var(--psb-ink)] \"\n        : down\n        ? \"translate-x-[5px] translate-y-[5px] shadow-none \"\n        : \"shadow-[5px_5px_0_0_var(--psb-ink)] hover:-translate-x-[2px] hover:-translate-y-[2px] hover:shadow-[7px_7px_0_0_var(--psb-ink)] \") +\n    className;\n\n  const style = {\n    \"--psb-ink\": line,\n    fontFamily: DISPLAY,\n    fontWeight: 800,\n    color: label,\n    // Set inline so no global border-colour rule can repaint it.\n    border: \"3px solid \" + line,\n    outlineColor: line,\n    background: disabled\n      ? \"repeating-linear-gradient(135deg, \" + line + \"26 0 3px, transparent 3px 9px), \" + fill\n      : fill,\n    opacity: disabled ? 0.72 : undefined,\n  } as CSSProperties;\n\n  const inner = (\n    <>\n      <span className={busy ? \"opacity-0\" : undefined}>{children}</span>\n      {arrow && !busy && (\n        <svg\n          aria-hidden\n          width={size === \"lg\" ? 22 : 18}\n          height={size === \"lg\" ? 16 : 14}\n          viewBox=\"0 0 18 14\"\n          fill=\"none\"\n          className=\"shrink-0 transition-transform duration-100 ease-linear group-hover:translate-x-[3px] motion-reduce:transition-none\"\n        >\n          <path d=\"M0 7H15M9 1.5L15 7L9 12.5\" stroke=\"currentColor\" strokeWidth=\"3\" strokeLinecap=\"square\" strokeLinejoin=\"miter\" />\n        </svg>\n      )}\n      {busy && (\n        <span className=\"absolute inset-0 flex items-center justify-center gap-[5px]\" role=\"status\" aria-label={busyLabel}>\n          {[0, 1, 2].map((i) => (\n            <span\n              key={i}\n              aria-hidden\n              className=\"psb-step size-[9px] motion-reduce:animate-none\"\n              style={{ background: \"currentColor\", animationDelay: i * 300 + \"ms\" }}\n            />\n          ))}\n          <style>{\"@keyframes psb-step{0%,32%{opacity:1}33%,100%{opacity:.18}}.psb-step{animation:psb-step 900ms steps(1,end) infinite}\"}</style>\n        </span>\n      )}\n    </>\n  );\n\n  const handlers = {\n    onPointerDown: (e: { button: number }) => {\n      if (e.button === 0) setPressed(true);\n    },\n    onPointerUp: release,\n    onPointerLeave: release,\n    onPointerCancel: release,\n    onKeyDown,\n    onKeyUp: release,\n    onBlur: release,\n  };\n\n  if (href && !disabled) {\n    return (\n      <a\n        href={safeHref(href)}\n        onClick={\n          busy\n            ? (e) => e.preventDefault()\n            : onClick\n        }\n        aria-busy={busy || undefined}\n        aria-disabled={busy || undefined}\n        className={cls}\n        style={style}\n        {...handlers}\n      >\n        {inner}\n      </a>\n    );\n  }\n  return (\n    <button\n      type={type}\n      disabled={disabled}\n      aria-busy={busy || undefined}\n      aria-disabled={busy || undefined}\n      onClick={busy ? (e) => e.preventDefault() : onClick}\n      className={cls}\n      style={style}\n      {...handlers}\n    >\n      {inner}\n    </button>\n  );\n}\n","type":"registry:component","target":"components/viberdy/pushed-shadow-button.tsx"}],"meta":{"tier":"free","tags":["button","neo-brutalism","brutalist","hard shadow","press","cta"],"href":"https://viberdy.dev/library/pushed-shadow-button","aiPrompt":"Create a React + Tailwind component, PushedShadowButton: a neo-brutalist button that behaves like a physical object sitting on its own hard shadow.\n\nProps (export PushedShadowButtonProps): children (default \"Start building\"), href? (renders a link; sanitise: strip tabs and newlines, refuse backslashes, allow http(s), mailto, tel and paths starting / # ? but not //), color (a kit fill \"yellow\" #ffd23f | \"coral\" #ff6b6b | \"blue\" #74b9ff | \"lime\" #b4f462 | \"pink\" #ff5fa2 | \"white\" | \"cream\" #fffdf5 | \"black\", or any #rrggbb, regex-validated; look named fills up by own key only), ink (#rrggbb, default #000000: the border, the shadow and the label on light fills), size (\"sm\" 40px tall, 14px text | \"md\" 48px, 16px | \"lg\" 64px, 20px), arrow (boolean), caps (uppercase at +0.04em), block (full width), busy (+ busyLabel, default \"Working\"), type, disabled, onClick, className.\n\nThe physics: a 3px solid border in the ink (set inline so no global border-colour rule can repaint it), square corners, and a hard shadow 5px 5px 0 0 in the ink with no blur. The shadow's far corner never moves: on hover the button translates -2px,-2px while the shadow grows to 7px; while pressed it translates 5px,5px and the shadow goes to none, so it lands exactly on the shadow. Transition only transform, translate and box-shadow (Tailwind v4 translate utilities set the translate property), 100ms linear, no easing and no scale; none under prefers-reduced-motion. Drive the press from state rather than :active so it shows for every input: pointer down (primary button) presses, pointer up, leave and cancel release; Enter (and Space on buttons, never on links) presses on keydown when not a repeat and releases on keyup and blur.\n\nType: var(--font-display, \"Archivo\", \"Archivo Black\", \"Arial Black\", \"Helvetica Neue\", Arial, sans-serif) at weight 800, leading 1, tracking -0.005em. The label reads in the ink on light fills and in cream #fffdf5 on dark fills (compute relative luminance; below 0.3 counts as dark, and flip to black if the ink itself is light). The arrow is an 18x14 SVG whose stroke is 3px with square caps and a miter join, so it matches the border; it steps 3px right on hover over the same 100ms.\n\nFocus-visible: a 3px dashed outline in the ink, 4px outside. Disabled: no shadow, no movement, a hatched fill (a repeating 135deg gradient of the ink at 15% alpha, 3px lines every 9px) over the colour, 72% opacity, not-allowed cursor. Busy: the button keeps its resting shadow but neither lifts nor presses, the label goes transparent (keeping the width), three 9px ink squares step on one after another (a 900ms keyframe with steps, 300ms apart) inside a role=\"status\" element labelled by busyLabel, aria-busy and aria-disabled are set, and clicks are prevented.\n\nWrite the focus-ring declarations as important utilities (for example focus-visible:![outline-style:dashed], focus-visible:![outline-offset:4px], focus-visible:![border-radius:0]) and keep the ring colour inline, so an unlayered global :focus-visible rule in the host app cannot replace them.\n\nOutput one self-contained \"use client\" TSX file with no dependencies beyond React. Build strings with + rather than template literals.","license":"https://viberdy.dev/license"}}