# Pulse Button

> A ring pulses outward to call attention.

Category: Buttons. Docs: https://neonkit.heyaadi.com/components/pulse-button

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/pulse-button.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/pulse-button`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { PulseButton } from "@/components/ui/pulse-button"

<PulseButton pulse={!seen} onClick={() => setSeen(true)}>Join the call</PulseButton>
```

## Examples

### Live session

Rings ping outward from the edge, keeping the button's corner radius as they grow.

```tsx
import { Video } from "@/components/ui/icons";
import { PulseButton } from "@/components/ui/pulse-button";

export default function PulseButtonDemo() {
  return (
    <div className="flex w-full max-w-sm flex-col items-center gap-6 rounded-2xl border border-white/10 bg-white/[0.03] px-6 py-8 text-center">
      <div className="space-y-1.5">
        <p className="text-base font-medium text-white/90">Design review: checkout redesign</p>
        <p className="text-balance text-[13px] leading-5 text-white/55">
          Maya is walking 38 people through the new payment flow.
        </p>
      </div>
      <PulseButton size="lg">
        <Video /> Join the call
      </PulseButton>
    </div>
  );
}
```

### Stop once seen

Turn pulse off after the user acts. The rings freeze where they are and fade out instead of vanishing.

```tsx
"use client";

import * as React from "react";
import { PulseButton } from "@/components/ui/pulse-button";

export default function PulseButtonAcknowledge() {
  const [seen, setSeen] = React.useState(false);

  return (
    <div className="flex flex-col items-center gap-3">
      <PulseButton variant="outline" pulse={!seen} onClick={() => setSeen(true)}>
        {seen ? "Release notes opened" : "See what's new in 1.2"}
      </PulseButton>
      <p className="text-xs text-white/50">
        {seen ? "The pulse has done its job and stops." : "Pulses until someone clicks it."}
      </p>
    </div>
  );
}
```

### Icon buttons

size="icon" is a round 40px button. A shorter period reads as more urgent.

```tsx
import { Bell, Mic } from "@/components/ui/icons";
import { PulseButton } from "@/components/ui/pulse-button";

export default function PulseButtonIcon() {
  return (
    <div className="flex items-center gap-8">
      <PulseButton size="icon" variant="outline" rings={1} aria-label="Notifications, 3 unread">
        <Bell />
      </PulseButton>
      <PulseButton size="icon" color="red" period={1.4} aria-label="Stop recording">
        <Mic />
      </PulseButton>
    </div>
  );
}
```

### Variants

```tsx
import { PulseButton } from "@/components/ui/pulse-button";

export default function PulseButtonVariants() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-8">
      <PulseButton variant="solid">Accept invite</PulseButton>
      <PulseButton variant="outline">Resume setup</PulseButton>
      <PulseButton variant="soft">Finish profile</PulseButton>
    </div>
  );
}
```

### Rings and period

Up to three rings in flight at once, spread evenly across the period.

```tsx
import { PulseButton } from "@/components/ui/pulse-button";

export default function PulseButtonRings() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-10">
      <PulseButton variant="outline" rings={1} period={2.8}>
        One ring, slow
      </PulseButton>
      <PulseButton variant="outline">Two rings</PulseButton>
      <PulseButton variant="outline" rings={3} period={1.8}>
        Three rings, urgent
      </PulseButton>
    </div>
  );
}
```

## API reference

### PulseButton

Also accepts every native button attribute. The ref points at the button (or the asChild element).

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `pulse` | `boolean` | `true` | Rings keep pulsing while true. Disabled buttons never pulse. |
| `period` | `number` | `2.2` | Seconds per pulse. |
| `rings` | `1 \| 2 \| 3` | `2` | Rings in flight at once, staggered across the period. |
| `variant` | `"solid" \| "outline" \| "soft"` | `"solid"` | The same faces as Button. |
| `size` | `"sm" \| "md" \| "lg" \| "icon"` | `"md"` | 32, 40 or 48px tall like Button; icon is a 40px circle. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |
| `asChild` | `boolean` | `false` | Render the child element (a link, say) with the pulse and merge props onto it. |
| `disabled` | `boolean` | `false` | Dims the button, blocks clicks and stops the pulse. |

## Accessibility

- The pulse is purely visual. If it signals something new (unread messages, a live session), say so in the label or aria-label too.
- Pulse sparingly: one per screen, and switch it off once the user has responded.
- Under prefers-reduced-motion a single still ring stays around the button instead of the animation.

## Source

`components/ui/pulse-button.tsx`

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

type RenderProp = { asChild?: boolean; render?: React.ReactElement; nativeButton?: boolean; children?: React.ReactNode };

/**
 * Base UI spells asChild as `render={<Link />}` plus `nativeButton`, and the shadcn CLI rewrites
 * asChild into that form in base-* projects. Folds it back into asChild and children.
 */
function fromRenderProp<P extends RenderProp>({ render, nativeButton, ...props }: P): Omit<P, "render" | "nativeButton"> {
  void nativeButton; // Only meaningful to Base UI.
  if (!render) return props;
  const children = props.children === undefined ? render : React.cloneElement(render, undefined, props.children);
  return { ...props, asChild: true, children };
}

// Server Components can hand children over as lazy references, which only resolve
// on the client. Unwrapping them here keeps server and client HTML the same.
const reactUse = (React as unknown as Record<string, unknown>)[" use ".trim()] as
  | (<T>(usable: PromiseLike<T>) => T)
  | undefined;

function unwrapLazy(node: React.ReactNode): React.ReactNode {
  const lazy = node as { $$typeof?: symbol; _payload?: { then?: unknown } } | null;
  if (
    reactUse &&
    lazy &&
    typeof lazy === "object" &&
    lazy.$$typeof === Symbol.for("react.lazy") &&
    typeof lazy._payload?.then === "function"
  ) {
    return reactUse(lazy._payload as PromiseLike<React.ReactNode>);
  }
  return node;
}

// Rings travel with outline-offset, so they grow by the same distance on every
// side and keep the button's corner radius (a scale would stretch a wide pill).
const css = `
@keyframes nk-pulse-button-ring{0%{outline-offset:0;opacity:.9}100%{outline-offset:var(--nk-pulse-button-reach);opacity:0}}
@keyframes nk-pulse-button-halo{0%,100%{opacity:.35}18%{opacity:1}}
.nk-pulse-button-ring{animation:nk-pulse-button-ring var(--nk-pulse-button-period) cubic-bezier(0.23,1,0.32,1) infinite both}
.nk-pulse-button-halo{animation:nk-pulse-button-halo var(--nk-pulse-button-period) ease-out infinite}
@media (prefers-reduced-motion:reduce){.nk-pulse-button-ring{animation:none;opacity:.45;outline-offset:3px}.nk-pulse-button-ring~.nk-pulse-button-ring{display:none}.nk-pulse-button-halo{animation:none;opacity:.6}}
`;

const variants = {
  solid:
    "bg-[rgb(var(--neon))] text-[#05060a] shadow-[0_0_0_1px_rgb(var(--neon)),0_0_18px_rgb(var(--neon)/CALC(0.45*var(--neon-glow,1))),inset_0_1px_0_rgb(255_255_255/0.45)] hover:shadow-[0_0_0_1px_rgb(var(--neon)),0_0_32px_rgb(var(--neon)/CALC(0.7*var(--neon-glow,1))),inset_0_1px_0_rgb(255_255_255/0.45)] hover:brightness-110",
  outline:
    "border border-[rgb(var(--neon)/0.7)] bg-[rgb(var(--neon)/0.06)] text-[rgb(var(--neon))] shadow-[0_0_12px_rgb(var(--neon)/CALC(0.25*var(--neon-glow,1))),inset_0_0_12px_rgb(var(--neon)/CALC(0.12*var(--neon-glow,1)))] [text-shadow:0_0_10px_rgb(var(--neon)/CALC(0.6*var(--neon-glow,1)))] hover:border-[rgb(var(--neon))] hover:bg-[rgb(var(--neon)/0.12)] hover:shadow-[0_0_24px_rgb(var(--neon)/CALC(0.5*var(--neon-glow,1))),inset_0_0_16px_rgb(var(--neon)/CALC(0.2*var(--neon-glow,1)))]",
  soft: "bg-[rgb(var(--neon)/0.12)] text-[rgb(var(--neon))] hover:bg-[rgb(var(--neon)/0.2)]",
} as const;

const sizes = {
  sm: { root: "h-8 gap-1.5 rounded-md px-3 text-xs [&_svg]:size-4", reach: 10 },
  md: { root: "h-10 gap-2 rounded-lg px-4 text-sm [&_svg]:size-4", reach: 13 },
  lg: { root: "h-12 gap-2.5 rounded-xl px-6 text-base [&_svg]:size-[18px]", reach: 16 },
  // A lone glyph carries the button, so it gets more of the circle.
  icon: { root: "size-10 rounded-full [&_svg]:size-[18px]", reach: 13 },
} as const;

type AnyProps = Record<string, unknown>;
type PossibleRef<T> = React.Ref<T> | undefined;

function setRef<T>(ref: PossibleRef<T>, value: T | null) {
  if (typeof ref === "function") ref(value);
  else if (ref) (ref as React.MutableRefObject<T | null>).current = value;
}

// React 19 reads `ref` from props; React 18 keeps it on the element.
function getElementRef(element: React.ReactElement) {
  if (Number.parseInt(React.version, 10) >= 19) {
    return (element.props as { ref?: React.Ref<HTMLElement> }).ref;
  }
  return (element as unknown as { ref?: React.Ref<HTMLElement> }).ref;
}

/** Our props under the child's: handlers both run (child first), classes and styles merge. */
function mergeProps(ours: AnyProps, theirs: AnyProps): AnyProps {
  const merged: AnyProps = { ...ours, ...theirs };
  for (const key of Object.keys(ours)) {
    const a = ours[key];
    const b = theirs[key];
    if (/^on[A-Z]/.test(key) && typeof a === "function" && typeof b === "function") {
      merged[key] = (...args: unknown[]) => {
        (b as (...args: unknown[]) => void)(...args);
        (a as (...args: unknown[]) => void)(...args);
      };
    } else if (key === "className") {
      merged[key] = cn(a as string, b as string);
    } else if (key === "style") {
      merged[key] = { ...(a as object), ...(b as object) };
    }
  }
  return merged;
}

export interface PulseButtonProps
  extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "color"> {
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  variant?: keyof typeof variants;
  /** `icon` is a round 40px button for a single glyph. */
  size?: keyof typeof sizes;
  /** Rings keep pulsing while true. Turn it off once the user has seen the thing. */
  pulse?: boolean;
  /** Seconds per pulse. */
  period?: number;
  /** Rings in flight at once, staggered across the period. */
  rings?: 1 | 2 | 3;
  /** Render the child element (e.g. a link) with the pulse. */
  asChild?: boolean;
  /** Base UI-style asChild: `render={<Link href="/" />}`. */
  render?: React.ReactElement;
  /** Accepted for Base UI compatibility; no effect. */
  nativeButton?: boolean;
}

const PulseButton = React.forwardRef<HTMLButtonElement, PulseButtonProps>(
  (raw, ref) => {
    const {
      className,
      style,
      color,
      variant = "solid",
      size = "md",
      pulse = true,
      period = 2.2,
      rings = 2,
      asChild = false,
      disabled,
      children,
      ...props
    } = fromRenderProp(raw);
    const s = sizes[size];
    const resolved = asChild ? unwrapLazy(children) : children;
    const child = asChild && React.isValidElement<AnyProps>(resolved) ? resolved : null;
    const childRef = child ? getElementRef(child) : undefined;
    const composedRef = React.useCallback(
      (node: HTMLButtonElement | null) => {
        setRef(ref, node);
        setRef(childRef as PossibleRef<HTMLButtonElement>, node);
      },
      [ref, childRef],
    );
    const active = pulse && !disabled;

    // Rings stay mounted: switching pulse off freezes them where they are and fades them out.
    const decorate = (label: React.ReactNode) => (
      <>
        <style>{css}</style>
        <span
          aria-hidden
          className={cn(
            "pointer-events-none absolute -inset-px rounded-[inherit] transition-opacity duration-300 ease-out",
            active ? "opacity-100" : "opacity-0",
            !active && "[&>span]:[animation-play-state:paused]",
          )}
        >
          <span className="nk-pulse-button-halo absolute inset-0 rounded-[inherit] shadow-[0_0_22px_1px_rgb(var(--neon)/CALC(0.45*var(--neon-glow,1)))]" />
          {Array.from({ length: rings }, (_, i) => (
            <span
              key={i}
              style={{ animationDelay: `${(-period * i) / rings}s` }}
              className="nk-pulse-button-ring absolute inset-0 rounded-[inherit] [outline:1.5px_solid_rgb(var(--neon)/0.75)]"
            />
          ))}
        </span>
        {label}
      </>
    );

    const rootProps: AnyProps = {
      style: neonVars(color, {
        "--nk-pulse-button-period": `${period}s`,
        "--nk-pulse-button-reach": `${s.reach}px`,
        ...style,
      } as React.CSSProperties),
      "data-pulse": active ? "" : undefined,
      className: cn(
        "[--neon:var(--neon-accent,34_211_238)]",
        "relative isolate inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap font-medium outline-none",
        "transition-[background-color,border-color,color,box-shadow,filter,transform,scale] duration-200 ease-out active:scale-[0.97]",
        "focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]",
        "disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50",
        "[&_svg]:pointer-events-none [&_svg]:shrink-0",
        s.root,
        variants[variant],
        className,
      ),
      ...props,
    };

    if (child) {
      return React.cloneElement(
        child,
        {
          ...mergeProps(rootProps, child.props),
          ...(disabled && { "aria-disabled": true, tabIndex: -1 }),
          ref: composedRef,
        } as AnyProps,
        decorate(child.props.children as React.ReactNode),
      );
    }

    return (
      <button ref={ref} disabled={disabled} {...(rootProps as React.ButtonHTMLAttributes<HTMLButtonElement>)}>
        {decorate(children)}
      </button>
    );
  },
);
PulseButton.displayName = "PulseButton";

export { PulseButton };
```
