{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"soft-glow-button","type":"registry:component","title":"Soft Glow Button","description":"A pill with a grained light inside it that follows the pointer with a slow settle, a diffused bloom that rises behind it, and a press that exhales a soft ring of light.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/soft-glow-button.tsx","content":"\"use client\";\n\nimport { useRef, useState } from \"react\";\nimport type { CSSProperties, PointerEvent as ReactPointerEvent, ReactNode } from \"react\";\n\n/**\n * SoftGlowButton — a pill with a light inside it.\n *\n * A soft, grained glow follows the pointer across the pill with a slow\n * settle (registered custom properties, so moving costs no renders) and a\n * diffused bloom rises behind it on hover or focus. A press sinks it\n * slightly and the light exhales: a soft ring spreads from the press and\n * fades. Solid is jade on the day ground and lit glass in the jade room;\n * quiet is a hairline pill whose light wakes under the pointer. Renders a\n * link when given an href, else a button.\n *\n * Part of the Diffused Glow kit: a warm bone day (#f6f1e6) and a jade room\n * (#0b120f); soft, grained halos in gold, rose or jade (or any #rrggbb), at\n * most two per view; a serif through var(--font-serif) over Geist. Respects\n * prefers-reduced-motion. No dependencies beyond React. Paste it as its own\n * file: it repeats the kit's small helpers, which would clash in one module.\n * For the serif, load Fraunces with next/font (variable: \"--font-serif\") on a parent.\n */\n\nconst SANS = 'var(--font-sans, \"Geist\", \"Inter\", ui-sans-serif, system-ui, sans-serif)';\n\nconst EASE = \"cubic-bezier(0.22, 1, 0.36, 1)\";\n\nconst HEX = /^#[0-9a-fA-F]{6}$/;\n\n/** The two grounds the kit is set on: the warm day and the jade room. */\nconst TONES = {\n  day: {\n    ground: \"#f6f1e6\",\n    panel: \"#efe7d6\",\n    raised: \"#fbf8f1\",\n    ink: \"rgba(34,31,26,0.92)\",\n    ink2: \"rgba(34,31,26,0.58)\",\n    ink3: \"rgba(34,31,26,0.4)\",\n    line: \"rgba(34,31,26,0.09)\",\n    line2: \"rgba(34,31,26,0.18)\",\n    shadow: \"0 30px 80px -20px rgba(40,30,10,0.14)\",\n  },\n  jewel: {\n    ground: \"#0b120f\",\n    panel: \"#101c17\",\n    raised: \"#16261f\",\n    ink: \"rgba(243,237,226,0.94)\",\n    ink2: \"rgba(243,237,226,0.63)\",\n    ink3: \"rgba(243,237,226,0.4)\",\n    line: \"rgba(243,237,226,0.08)\",\n    line2: \"rgba(243,237,226,0.16)\",\n    shadow: \"0 40px 100px -24px rgba(0,0,0,0.5)\",\n  },\n} as const;\n\nexport type DgTone = keyof typeof TONES;\n\n/** The bronze accent, used only as light. */\nconst BRONZE = \"#c9a15e\";\n\n/** The one filled action in the kit: booking. */\nconst JADE_FILL = \"#2f6f57\";\n\n/** Halo colours. Gold and rose belong to the jade room; jade is the day's own light. */\nexport const DG_GLOWS = {\n  gold: \"#f5c48c\",\n  rose: \"#f2a39a\",\n  jade: \"#bcd9c8\",\n} as const;\n\nfunction own<T extends object>(map: T, key: string): key is Extract<keyof T, string> {\n  return Object.prototype.hasOwnProperty.call(map, key);\n}\n\n/** A named glow or a #rrggbb; anything else falls back to gold. */\nfunction glowHex(value: string): string {\n  if (own(DG_GLOWS, value)) return DG_GLOWS[value];\n  return HEX.test(value) ? value : DG_GLOWS.gold;\n}\n\nfunction toneOf(value: string) {\n  return own(TONES, value) ? TONES[value] : TONES.day;\n}\n\nfunction rgbOf(hex: string): [number, number, number] {\n  const n = parseInt(hex.slice(1), 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\n\nfunction rgba(hex: string, a: number): string {\n  const [r, g, b] = rgbOf(hex);\n  return `rgba(${r},${g},${b},${a})`;\n}\n\n/**\n * Grain as an SVG turbulence tile. It is only ever laid inside a light (masked\n * to the halo's shape), never over the whole page: grain belongs to the glow.\n */\nconst GRAIN_URL = `url(\"data:image/svg+xml,${encodeURIComponent(\n  \"<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='g'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.5  0 0 0 0 0.5  0 0 0 0 0.5  0 0 0 0.9 0'/></filter><rect width='160' height='160' filter='url(#g)'/></svg>\",\n)}\")`;\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\n// Each part carries only the CSS it needs, so any one of them works on its own.\n// Nothing is interpolated into these sheets; colours arrive as CSS variables.\n// revert-layer keeps an element's own rounded corners when a host page's focus rule flattens them.\nconst FOCUS_CSS = \".dg-scope :focus-visible,.dg-scope:focus-visible{outline:2px solid var(--dg-ring);outline-offset:3px;border-radius:revert-layer}\";\n\n/** The focus ring colour for a ground: bronze light on the jade room, jade on the day. */\nfunction ringVar(tone: string): CSSProperties {\n  return { \"--dg-ring\": tone === \"jewel\" ? BRONZE : JADE_FILL } as CSSProperties;\n}\n\n// Registered, so the inner light glides in CSS (a soft 600ms settle) without a render per pointer move.\nconst GLOW_BTN_CSS =\n  \"@property --dg-gx{syntax:'<percentage>';inherits:true;initial-value:30%}\" +\n  \"@property --dg-gy{syntax:'<percentage>';inherits:true;initial-value:30%}\" +\n  \".dg-glow{transition:--dg-gx 600ms \" + EASE + \",--dg-gy 600ms \" + EASE + \",transform 420ms \" + EASE + \",background-color 300ms ease}\" +\n  \".dg-glow:active{transform:scale(0.97)}\" +\n  \".dg-glow .dg-bloom{opacity:0;transform:scale(0.85);transition:opacity 450ms \" + EASE + \",transform 450ms \" + EASE + \"}\" +\n  \".dg-glow:hover .dg-bloom,.dg-glow:focus-visible .dg-bloom{opacity:1;transform:scale(1)}\" +\n  \"@keyframes dg-exhale{from{transform:translate(-50%,-50%) scale(0.3);opacity:0.9}to{transform:translate(-50%,-50%) scale(1.6);opacity:0}}\" +\n  \"@media (prefers-reduced-motion: reduce){.dg-glow,.dg-glow .dg-bloom{transition:none}.dg-glow:active{transform:none}.dg-exhale{display:none}}\";\n\nexport type SoftGlowButtonProps = {\n  children: ReactNode;\n  /** Renders a link when set, else a button. */\n  href?: string;\n  onClick?: () => void;\n  /** The ground it sits on: \"jewel\" (the dark jade room) or \"day\" (warm bone). */\n  tone?: DgTone;\n  /** \"solid\": the one booking action. \"quiet\": a hairline pill for the second action. */\n  variant?: \"solid\" | \"quiet\";\n  /** The light's colour: gold, rose, jade or a #rrggbb. */\n  glow?: string;\n  size?: \"md\" | \"lg\";\n  type?: \"button\" | \"submit\";\n  disabled?: boolean;\n  className?: string;\n};\n\n/**\n * A pill with a light inside it. A soft, grained glow follows the pointer\n * across the pill with a slow settle (eased in CSS through registered custom\n * properties, so moving costs no renders), and a diffused bloom rises behind\n * it on hover or focus. A press sinks the pill slightly and the light exhales:\n * a soft ring spreads from where you pressed and fades. Solid is jade on the\n * day ground and lit glass in the jade room; quiet is a hairline pill whose\n * light only wakes under the pointer.\n */\nexport function SoftGlowButton({\n  children,\n  href,\n  onClick,\n  tone = \"day\",\n  variant = \"solid\",\n  glow,\n  size = \"md\",\n  type = \"button\",\n  disabled,\n  className,\n}: SoftGlowButtonProps) {\n  const t = toneOf(tone);\n  const light = glowHex(glow ?? (tone === \"jewel\" ? \"gold\" : \"jade\"));\n  const [exhales, setExhales] = useState<{ id: number; x: number; y: number }[]>([]);\n  const nextId = useRef(0);\n  const solid = variant === \"solid\";\n  const dark = tone === \"jewel\";\n\n  const onMove = (e: ReactPointerEvent<HTMLElement>) => {\n    const r = e.currentTarget.getBoundingClientRect();\n    e.currentTarget.style.setProperty(\"--dg-gx\", `${(((e.clientX - r.left) / (r.width || 1)) * 100).toFixed(1)}%`);\n    e.currentTarget.style.setProperty(\"--dg-gy\", `${(((e.clientY - r.top) / (r.height || 1)) * 100).toFixed(1)}%`);\n  };\n  const onDown = (e: ReactPointerEvent<HTMLElement>) => {\n    const r = e.currentTarget.getBoundingClientRect();\n    const id = ++nextId.current;\n    setExhales((all) => [...all.slice(-2), { id, x: e.clientX - r.left, y: e.clientY - r.top }]);\n  };\n\n  const h = size === \"lg\" ? 54 : 46;\n  const fill = solid ? (dark ? \"rgba(243,237,226,0.08)\" : JADE_FILL) : \"transparent\";\n  const style: CSSProperties = {\n    ...ringVar(tone),\n    height: h,\n    padding: size === \"lg\" ? \"0 30px\" : \"0 22px\",\n    fontFamily: SANS,\n    fontSize: size === \"lg\" ? 16 : 15,\n    fontWeight: 500,\n    color: solid && !dark ? \"#f6f1e6\" : t.ink,\n    backgroundColor: fill,\n    boxShadow: solid\n      ? dark\n        ? `inset 0 0 0 1px ${t.line2}, inset 0 1px 0 rgba(243,237,226,0.12)`\n        : \"inset 0 1px 0 rgba(255,255,255,0.18), 0 18px 40px -18px rgba(47,111,87,0.6)\"\n      : `inset 0 0 0 1px ${t.line2}`,\n    opacity: disabled ? 0.5 : 1,\n    cursor: disabled ? \"not-allowed\" : \"pointer\",\n  };\n  const inner = (\n    <>\n      {/* The bloom behind the pill. */}\n      <span\n        aria-hidden\n        className=\"dg-bloom pointer-events-none absolute -inset-x-[30%] -inset-y-[90%] -z-10 rounded-full\"\n        style={{ background: `radial-gradient(closest-side, ${rgba(light, dark ? 0.32 : 0.45)}, ${rgba(light, 0)})`, filter: \"blur(6px)\" }}\n      />\n      {/* The light inside it, grained, following the pointer. */}\n      <span aria-hidden className=\"pointer-events-none absolute inset-0 overflow-hidden rounded-full\">\n        <span\n          className=\"absolute inset-0\"\n          style={{\n            background: `radial-gradient(60% 120% at var(--dg-gx) var(--dg-gy), ${rgba(light, solid ? (dark ? 0.42 : 0.5) : 0.3)}, ${rgba(light, 0)} 70%)`,\n            opacity: solid ? 1 : 0.9,\n          }}\n        />\n        <span\n          className=\"absolute inset-0\"\n          style={{\n            backgroundImage: GRAIN_URL,\n            mixBlendMode: \"overlay\",\n            opacity: 0.5,\n            WebkitMaskImage: \"radial-gradient(60% 120% at var(--dg-gx) var(--dg-gy), #000, transparent 70%)\",\n            maskImage: \"radial-gradient(60% 120% at var(--dg-gx) var(--dg-gy), #000, transparent 70%)\",\n          }}\n        />\n        {exhales.map((x) => (\n          <span\n            key={x.id}\n            className=\"dg-exhale absolute rounded-full\"\n            onAnimationEnd={() => setExhales((all) => all.filter((y) => y.id !== x.id))}\n            style={{\n              left: x.x,\n              top: x.y,\n              width: h * 3,\n              height: h * 3,\n              background: `radial-gradient(closest-side, ${rgba(light, 0.55)}, ${rgba(light, 0)})`,\n              animation: `dg-exhale 900ms ${EASE} forwards`,\n            }}\n          />\n        ))}\n      </span>\n      <span className=\"relative inline-flex items-center gap-2\">{children}</span>\n    </>\n  );\n  const cls =\n    \"dg-scope dg-glow relative isolate inline-flex select-none items-center justify-center whitespace-nowrap rounded-full\" +\n    (className ? \" \" + className : \"\");\n  return (\n    <>\n      <style>{GLOW_BTN_CSS + FOCUS_CSS}</style>\n      {href && !disabled ? (\n        <a href={safeHref(href)} onClick={onClick} onPointerMove={onMove} onPointerDown={onDown} className={cls} style={style}>\n          {inner}\n        </a>\n      ) : (\n        <button type={type} onClick={onClick} onPointerMove={onMove} onPointerDown={disabled ? undefined : onDown} disabled={disabled} className={cls} style={style}>\n          {inner}\n        </button>\n      )}\n    </>\n  );\n}\n","type":"registry:component","target":"components/viberdy/soft-glow-button.tsx"}],"meta":{"tier":"free","tags":["button","glow","grain","pointer","press","css","spa"],"href":"https://viberdy.dev/library/soft-glow-button","aiPrompt":"Build a React + Tailwind button, `SoftGlowButton({ children, href, onClick, tone, variant, glow, size, type, disabled })`, a pill with a light inside it.\n\nRegister --dg-gx and --dg-gy as <percentage> custom properties with @property and transition them over 600ms, so the light eases in CSS. Inside the pill (overflow hidden, full radius) lay a radial gradient of the glow colour at var(--dg-gx) var(--dg-gy) (60% by 120%, fading by 70%) and an SVG feTurbulence grain tile in overlay blend masked to the same radial, so the grain lives in the light. Behind the pill (a negative-inset span, blurred 6px) a closest-side radial bloom that fades and scales in over 450ms on hover or focus-visible. On pointermove write the two variables from the pointer's position in the pill. On pointerdown add a ring at the press point: a radial gradient that scales from 0.3 to 1.6 and fades over 900ms, then remove it. Solid: jade #2f6f57 with bone text on the day ground, translucent glass with an inset hairline in the jade room; quiet: a hairline pill. The pill sinks to scale(0.97) while pressed. Render an anchor with a sanitised href, else a button.\n\nDiffused Glow grammar: two grounds, a warm bone day (#f6f1e6; panels #efe7d6, raised #fbf8f1; ink #221f1a at 92/58/40%; hairlines at 9% and 18%) and a near-black jade room (#0b120f; panels #101c17 and #16261f; ink #f3ede2 at 94/63/40%). Light is soft and grained: halos in gold #f5c48c, rose #f2a39a and, on the day ground, jade #bcd9c8 (or any #rrggbb validated against /^#[0-9a-fA-F]{6}$/), never more than two per view, each a wide gaussian skirt with a warmer core, and the grain lives inside the light (an SVG feTurbulence tile masked to the halo, or noise in the shader), never as a page overlay. In the jade room light is added (screen blend); on the day ground it pools as colour (the paper takes the tint). One bronze accent #c9a15e used as light and one jade fill #2f6f57 kept for the booking action; one filled action per view. A serif (Fraunces by default, through var(--font-serif, \"Fraunces\", Georgia, serif)) at 340-400 for display with 0.98-1.04 leading and *italic* accents, over Geist for UI and Geist Mono uppercase captions. Radii 24-28px on panels, pills for controls; feathered shadows only. Motion is slow: halos breathe on about 10s, drift on 36s or longer and settle toward the pointer over about 900ms (1 - e^(-3dt)); resolves run 700-1100ms out of a blur on cubic-bezier(0.16, 1, 0.3, 1); hovers under 450ms. Keyframes and a focus ring (a .dg-scope rule with border-radius: revert-layer) live in a small static <style> the component renders itself; nothing is interpolated into it. Respect prefers-reduced-motion. No dependencies beyond React.","license":"https://viberdy.dev/license"}}