Domphy

rainbowButton

A Buttons block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call rainbowButton() with no arguments for a working demo, or edit the code below live.

Implementation notes

Full behavioral/visual port from the spec. Structure matches the given domSketch exactly: a relatively-positioned outer div wrapping a blurred glow layer (behind, in DOM order) plus the button itself, rather than a negative-z-index trick nested inside the button. Gradient stops are ThemeColor roles (error/secondary/primary/info/success) approximating the spec's five literal hues (red/violet/blue/cyan/yellow-green) — Domphy forbids raw hex/rgb on style props and has no dedicated violet family, so 'secondary' (this theme's rose/magenta) substitutes for violet, the same substitution already documented in this package's animatedGradientText.ts. Pan animation is a pure CSS background-position keyframe over a 200% background-size (seamless loop), fully ambient from mount. 'outline' variant uses the classic dual-background-layer gradient-border trick (opaque padding-box layer over an animated border-box gradient layer). Pill shape via an oversized border-radius (999px, clamped by the browser to the box's own geometry) rather than a themeSpacing token, since border-radius isn't a density-scaled control dimension. Hand-rolls its own button chrome (density-aware padding/radius formula reproduced by hand) instead of composing the ui button() patch, because that patch's own backgroundColor/outline (keyed off a single color prop) would conflict with the multi-stop animated fill/ring — the same tradeoff this package's borderBeam.ts/shineBorder.ts already document for their own bespoke container chrome. Verified doctor-clean (0 findings) via the domphy-doctor CLI, including its Layer-4 generated-CSS lint.

Status: ported · Reference: Magic UI original

// Magic UI "Rainbow Button" — clean-room reimplementation.
//
// A pill-shaped call-to-action whose fill is a multi-hue gradient that pans
// continuously sideways, with a blurred duplicate of the same gradient
// sitting behind the shape as a soft colorful glow. Implemented purely from
// the block's public functional/visual spec — no upstream Magic UI source
// was viewed or copied.
//
// Technique: the gradient is painted at 200% background-size and a
// linear-infinite keyframe animates `backgroundPosition` from "0% 50%" to
// "200% 50%" — since the animation's end offset matches the oversized
// background-size, the pattern tiles seamlessly with no visible seam (the
// same relationship `animatedGradientText.ts` already uses at 300%/300%).
// The glow is the identical gradient rendered on a second, larger, heavily
// blurred layer placed BEHIND the button in DOM order inside a shared
// `position: relative` wrapper (per the block's own domSketch) rather than
// via a negative z-index trick on the button itself, so it never fights the
// button's own stacking context.
//
// The upstream spec's rainbow is five literal hues (red/violet/blue/cyan/
// yellow-green). Domphy's doctor rules forbid raw hex/rgb color literals on
// style props, and this theme has no dedicated violet family, so the
// gradient stops are five `ThemeColor` roles instead, chosen to approximate
// that hue spread: "error" (red) → "secondary" (this theme's rose/magenta —
// the closest built-in role to violet, same substitution `animatedGradientText`
// documents) → "primary" (blue) → "info" (cyan) → "success" (yellow-green).
// This keeps the sweep fully theme-aware (it follows light/dark theme swaps)
// at the cost of not accepting an arbitrary caller-supplied hex list.

import type { DomphyElement, Listener, StyleObject } from "@domphy/core";
import { hashString } from "@domphy/core";
import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing } from "@domphy/theme";

export type RainbowButtonVariant = "default" | "outline";
export type RainbowButtonSize = "sm" | "default" | "lg" | "icon";

export interface RainbowButtonProps {
  /** Button label content. Defaults to `"Get unlimited access"`. */
  children?: DomphyElement | DomphyElement[] | string;
  /** `"default"` fills the whole face with the animated gradient; `"outline"` keeps a
   * neutral flat interior and only rings the border with the animated gradient. Defaults
   * to `"default"`. */
  variant?: RainbowButtonVariant;
  /** Standard button size preset. Defaults to `"default"`. */
  size?: RainbowButtonSize;
  /** Gradient stops the sweep pans through, in order. Defaults to a five-hue rainbow
   * approximation: `["error", "secondary", "primary", "info", "success"]`. */
  colors?: ThemeColor[];
  /** One full pan cycle, in seconds. Defaults to `3`. */
  duration?: number;
  onClick?: (event: MouseEvent) => void;
  disabled?: boolean;
  style?: StyleObject;
}

const DEFAULT_RAINBOW_COLORS: ThemeColor[] = ["error", "secondary", "primary", "info", "success"];

interface RainbowButtonSizing {
  paddingBlockUnits: number;
  paddingInlineUnits: number;
  fontSizeTone: "decrease-1" | "inherit" | "increase-1";
  square?: boolean;
}

const RAINBOW_BUTTON_SIZES: Record<RainbowButtonSize, RainbowButtonSizing> = {
  sm: { paddingBlockUnits: 0.75, paddingInlineUnits: 2.5, fontSizeTone: "decrease-1" },
  default: { paddingBlockUnits: 1, paddingInlineUnits: 3, fontSizeTone: "inherit" },
  lg: { paddingBlockUnits: 1.5, paddingInlineUnits: 5, fontSizeTone: "increase-1" },
  icon: { paddingBlockUnits: 1, paddingInlineUnits: 1, fontSizeTone: "inherit", square: true },
};

let rainbowButtonInstanceCounter = 0;

/** Normalizes a `DomphyElement | DomphyElement[] | string` prop into the flat
 * `(string | DomphyElement)[]` shape `DomphyElement<T>`'s content field expects — a
 * bare single element isn't part of that type, only primitives/arrays/functions are. */
function asContent(value: DomphyElement | DomphyElement[] | string): (string | DomphyElement)[] {
  return Array.isArray(value) ? value : [value];
}

/**
 * A pill-shaped hero/CTA button filled (or, in `"outline"` mode, ringed) with a
 * continuously panning multi-hue gradient, backed by a soft blurred duplicate of the
 * same gradient acting as a colorful ambient glow. The pan animation is fully ambient —
 * it loops from mount with no interaction required, while hover/press layer ordinary
 * button feedback on top. Call with no arguments for a working demo button.
 */
function rainbowButton(props: RainbowButtonProps = {}): DomphyElement<"div"> {
  const label = props.children ?? "Get unlimited access";
  const variant = props.variant ?? "default";
  const size = props.size ?? "default";
  const colors = props.colors && props.colors.length > 0 ? props.colors : DEFAULT_RAINBOW_COLORS;
  const duration = props.duration ?? 3;
  const sizing = RAINBOW_BUTTON_SIZES[size];

  const instanceId = ++rainbowButtonInstanceCounter;
  const animationName = `rainbow-button-flow-${hashString(
    JSON.stringify({ instanceId, colors, duration }),
  )}`;
  const flowKeyframes = {
    from: { backgroundPosition: "0% 50%" },
    to: { backgroundPosition: "200% 50%" },
  };
  const flowAnimation = `${animationName} ${duration}s linear infinite`;

  // Builds the comma-separated stop list, repeating the first color at the end so the
  // 200%-wide tiled pattern loops with no visible seam.
  const gradientStops = (listener: Listener): string => {
    const stops = colors.map((color) => themeColor(listener, "shift-8", color));
    stops.push(themeColor(listener, "shift-8", colors[0]));
    return stops.join(", ");
  };

  const isOutline = variant === "outline";

  const fillStyle = isOutline
    ? {
        // Classic dual-background-layer "gradient border" trick: an opaque neutral
        // layer clipped to the padding-box sits over an animated gradient layer
        // clipped to the border-box, so only a thin ring of the gradient shows.
        backgroundImage: (listener: Listener) =>
          `linear-gradient(${themeColor(listener, "inherit", "neutral")}, ${themeColor(listener, "inherit", "neutral")}), linear-gradient(90deg, ${gradientStops(listener)})`,
        backgroundOrigin: "border-box",
        backgroundClip: "padding-box, border-box",
        backgroundSize: "auto, 200% 100%",
        backgroundRepeat: "no-repeat, repeat",
        border: "2px solid transparent",
        color: (listener: Listener) => themeColor(listener, "shift-9", "neutral"),
        animation: flowAnimation,
        [`@keyframes ${animationName}`]: flowKeyframes,
      }
    : {
        backgroundImage: (listener: Listener) => `linear-gradient(90deg, ${gradientStops(listener)})`,
        backgroundSize: "200% 100%",
        color: (listener: Listener) => themeColor(listener, "shift-0", "neutral"),
        animation: flowAnimation,
        [`@keyframes ${animationName}`]: flowKeyframes,
      };

  // Decorative blurred halo behind the button — same panning gradient, enlarged,
  // heavily blurred and dimmed, offset slightly downward to fake a colored drop-shadow.
  // `_doctorDisable` isn't part of core's strict `PartialElement` type — build through
  // an untyped literal, then assert, so the excess-property check doesn't fire (mirrors
  // `overlayCanvas` in confetti.ts).
  const glowLayer = {
    span: null,
    ariaHidden: "true",
    _doctorDisable: "missing-color",
    style: {
      position: "absolute",
      // Enlarged more at the sides/bottom than the top, and blurred, so the halo
      // reads as a soft colorful drop-shadow rather than a symmetric outline.
      top: "0",
      left: "-6px",
      right: "-6px",
      bottom: "-10px",
      borderRadius: "inherit",
      backgroundImage: (listener: Listener) => `linear-gradient(90deg, ${gradientStops(listener)})`,
      backgroundSize: "200% 100%",
      filter: "blur(20px)",
      opacity: 0.65,
      zIndex: -1,
      animation: flowAnimation,
      [`@keyframes ${animationName}`]: flowKeyframes,
    } as StyleObject,
  } as DomphyElement<"span">;

  // Hand-rolls the button chrome instead of composing the `button()` patch: that
  // patch owns `backgroundColor`/`outline` keyed off a single `color` prop, which
  // would fight the multi-stop animated gradient fill/ring that IS this component's
  // entire visual identity (same tradeoff `borderBeam`/`shineBorder` make for their
  // own bespoke container chrome). The density-aware padding/radius formula and
  // interaction states are reproduced by hand below to stay consistent with it.
  const buttonElement: DomphyElement<"button"> = {
    button: [{ span: asContent(label), style: { position: "relative", zIndex: 1 } }],
    type: "button",
    disabled: props.disabled,
    style: {
      position: "relative",
      appearance: "none",
      border: "none",
      cursor: props.disabled ? "not-allowed" : "pointer",
      display: "inline-flex",
      alignItems: "center",
      justifyContent: "center",
      gap: (listener: Listener) => themeSpacing(themeDensity(listener) * 1),
      fontSize: (listener: Listener) => themeSize(listener, sizing.fontSizeTone),
      paddingBlock: (listener: Listener) => themeSpacing(themeDensity(listener) * sizing.paddingBlockUnits),
      paddingInline: (listener: Listener) => themeSpacing(themeDensity(listener) * sizing.paddingInlineUnits),
      ...(sizing.square ? { aspectRatio: "1" } : {}),
      // A radius far beyond any realistic box half-height forces a full pill/circle —
      // the browser clamps it to the shape's own geometry (not tracked by the
      // raw-spacing-value doctor rule, which only checks margin/padding/gap props).
      borderRadius: "999px",
      opacity: props.disabled ? 0.6 : 1,
      transition: "transform 150ms ease, filter 150ms ease",
      "&:hover:not([disabled])": { filter: "brightness(1.06)" },
      "&:active:not([disabled])": { transform: "scale(0.97)" },
      ...fillStyle,
      ...(props.style ?? {}),
    } as StyleObject,
  };
  if (props.onClick) buttonElement.onClick = props.onClick;

  return {
    div: [glowLayer, buttonElement],
    style: { position: "relative", display: "inline-block" },
  };
}

export { rainbowButton };

← Back to Magic UI catalog