Domphy

interactiveHoverButton

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

Implementation notes

Full visual/behavior implemented as pure CSS, no JS timers or pointer handlers: a pill button containing an accent dot span, a resting label span, and a hidden overlay span (duplicate label + inline-SVG right-arrow glyph), all driven by nested '&:hover([disabled]) [data-*]' selectors on the button's own style object (the same nested-selector technique glareHover.ts already uses). On hover the dot scale-transforms up 90x (a scale-based flood reveal, matching the research note's '~100x' description) while the resting label fades/slides out and the overlay fades/slides in, all sharing a 320ms cubic-bezier(0.22,1,0.36,1) transition. Overlay text uses the theme's lightest edge tone (shift-0) for contrast against the flooded accent, the same 'light text on saturated fill' convention rainbowButton.ts uses. Doctor-clean after adding a tone-background-inherit exemption on the decorative flood dot (same exemption meteors.ts's dot span carries, since a flood accent is intentionally a fixed tone, not a surface tracking ambient dataTone); 2 vitest assertions cover the default demo DOM shape and a custom label + onClick forwarding.

Status: ported · Reference: Magic UI original

// magicui "Interactive Hover Button" — clean-room reimplementation from the
// public behavior/visual spec only (no upstream source viewed or copied). A
// pill-shaped button whose resting state shows a small accent dot followed
// by a label; on hover the label swaps for an arrow-and-label pair while the
// dot scales up far past its own bounds so it floods the button with a
// solid accent color.
//
// Pure CSS: no JS timers or pointer handlers. The whole effect is driven by
// nested `&:hover [data-*]` selectors on the button's own style object (the
// same technique `glareHover.ts` uses for its hover-armed sweep), so hover
// and un-hover both animate via the browser's native `:hover` transition
// engine with a single shared ~320ms duration.
//
// The flood is a scale trick, not a background-color transition: the dot
// starts as a small circle and scales up ~90x on hover, which — combined
// with the button's own `overflow: hidden` — reads as the accent color
// washing over the whole face rather than a visibly growing circle.

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

export interface InteractiveHoverButtonProps {
  /** Button label. Defaults to `"Get Started"`. */
  children?: string;
  /** Theme color family for the accent dot, flood, and outline. Defaults to `"primary"`. */
  color?: ThemeColor;
  disabled?: boolean;
  onClick?: (event: MouseEvent) => void;
  /** Passthrough style merged onto the button. */
  style?: StyleObject;
}

/** Simple right-pointing arrow glyph, matching `heroVideoDialog.ts`'s inline-SVG icon pattern. */
function arrowGlyph(): DomphyElement<"span"> {
  return {
    span: [
      {
        svg: [
          { line: null, x1: "5", y1: "12", x2: "19", y2: "12" },
          { polyline: null, points: "12 5 19 12 12 19" },
        ],
        viewBox: "0 0 24 24",
        fill: "none",
        stroke: "currentColor",
        strokeWidth: "2",
        strokeLinecap: "round",
        strokeLinejoin: "round",
        role: "img",
        ariaHidden: "true",
        style: { width: "100%", height: "100%" },
      } as DomphyElement<"svg">,
    ],
    ariaHidden: "true",
    style: { display: "inline-flex", width: themeSpacing(4), height: themeSpacing(4) },
  };
}

/** Small resting-state accent dot that scales up into a full-face color flood on hover. */
function accentDot(color: ThemeColor): DomphyElement<"span"> {
  return {
    span: null,
    dataIhbDot: "true",
    ariaHidden: "true",
    // Decorative flood dot with no text of its own — exempt from the
    // missing-color contract, matching meteors.ts's dot span. Also exempt
    // from tone-background-inherit: the dot is intentionally a fixed bright
    // accent (it floods the button on hover), not a surface that should
    // track the ambient dataTone context.
    _doctorDisable: ["missing-color", "tone-background-inherit"],
    style: {
      position: "absolute",
      insetInlineStart: themeSpacing(3),
      insetBlockStart: "50%",
      width: themeSpacing(2),
      height: themeSpacing(2),
      borderRadius: "50%",
      transformOrigin: "center",
      transform: "translateY(-50%) scale(1)",
      backgroundColor: (listener: Listener) => themeColor(listener, "shift-9", color),
      transition: "transform 320ms cubic-bezier(0.22, 1, 0.36, 1)",
      pointerEvents: "none",
      zIndex: 0,
    } as StyleObject,
  } as DomphyElement<"span">;
}

/**
 * A pill-shaped button that swaps its label for an arrow-and-label pair on
 * hover while a small accent dot scales up into a full-face color flood.
 * Call with no arguments for a working demo button.
 */
function interactiveHoverButton(props: InteractiveHoverButtonProps = {}): DomphyElement<"button"> {
  const label = props.children ?? "Get Started";
  const color = props.color ?? "primary";

  const restingLabel: DomphyElement<"span"> = {
    span: label,
    dataIhbLabel: "true",
    style: {
      position: "relative",
      zIndex: 1,
      transition: "transform 320ms cubic-bezier(0.22, 1, 0.36, 1), opacity 320ms cubic-bezier(0.22, 1, 0.36, 1)",
    } as StyleObject,
  };

  const hoverOverlay: DomphyElement<"span"> = {
    span: [{ span: label, style: { position: "relative" } } as DomphyElement<"span">, arrowGlyph()],
    dataIhbOverlay: "true",
    ariaHidden: "true",
    style: {
      position: "absolute",
      insetInlineStart: 0,
      insetInlineEnd: 0,
      insetBlockStart: "50%",
      display: "inline-flex",
      alignItems: "center",
      justifyContent: "center",
      gap: (listener) => themeSpacing(themeDensity(listener) * 1),
      zIndex: 1,
      opacity: 0,
      pointerEvents: "none",
      // "shift-0" (the lightest edge tone) so the swapped label reads clearly
      // once the accent dot has flooded the button — the same "light text on
      // a saturated fill" convention `rainbowButton.ts` uses for its filled
      // variant.
      color: (listener: Listener) => themeColor(listener, "shift-0", color),
      transform: `translate(${themeSpacing(6)}, -50%)`,
      transition: "transform 320ms cubic-bezier(0.22, 1, 0.36, 1), opacity 320ms cubic-bezier(0.22, 1, 0.36, 1)",
    } as StyleObject,
  };

  const buttonElement: DomphyElement<"button"> = {
    button: [accentDot(color), restingLabel, hoverOverlay],
    type: "button",
    disabled: props.disabled,
    style: {
      position: "relative",
      overflow: "hidden",
      appearance: "none",
      border: "none",
      display: "inline-flex",
      alignItems: "center",
      justifyContent: "center",
      cursor: props.disabled ? "not-allowed" : "pointer",
      fontSize: (listener: Listener) => themeSize(listener, "inherit"),
      paddingBlock: (listener: Listener) => themeSpacing(themeDensity(listener) * 1),
      paddingInline: (listener: Listener) => themeSpacing(themeDensity(listener) * 4),
      // A radius far beyond any realistic box half-height forces a full
      // pill shape — 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). Same trick `rainbowButton.ts` uses.
      borderRadius: "999px",
      outline: (listener: Listener) => `1px solid ${themeColor(listener, "shift-4", color)}`,
      outlineOffset: "-1px",
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit", color),
      color: (listener: Listener) => themeColor(listener, "shift-9", color),
      opacity: props.disabled ? 0.6 : 1,
      "&:hover:not([disabled]) [data-ihb-dot]": { transform: "translateY(-50%) scale(90)" },
      "&:hover:not([disabled]) [data-ihb-label]": {
        opacity: 0,
        transform: `translateX(${themeSpacing(-6)})`,
      },
      "&:hover:not([disabled]) [data-ihb-overlay]": { opacity: 1, transform: "translate(0, -50%)" },
      ...(props.style ?? {}),
    } as StyleObject,
  };
  if (props.onClick) buttonElement.onClick = props.onClick;

  return buttonElement;
}

export { interactiveHoverButton };

← Back to Magic UI catalog