Domphy

gooeyInput

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

Implementation notes

Full functional port of the classic SVG goo-filter recipe (feGaussianBlur -> feColorMatrix threshold -> feBlend, referenced via filter: url(#id)) applied only to the icon+pill 'chrome' group and only while a transition is in flight, per the spec's own performance guidance; icon bubble stays pinned while the pill box grows/moves via motion() driven by a shared State<MotionKeyframe> so width/left tween in lockstep, auto-focusing the field on open and closing on outside-click/Escape/re-click. Uses the spec's own documented sizing defaults (collapsed 115px, expanded 200px, offset 50px, blur strength 5). One source-level fix required to make this correct in real browsers: stdDeviation (and colorInterpolationFilters) are literal-camelCase SVG presentation attributes that were missing from packages/core/src/constants/CamelAttributes.ts, so they would have serialized to the DOM as the invalid std-deviation attribute; added additively (same class of gap this package's own squigglyText.ts had already found and fixed for feTurbulence's baseFrequency/numOctaves), and packages/core was rebuilt so the fix is live. No visual/behavioral gaps remain versus the spec.

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Gooey Input" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A
// compact icon-sized search trigger that morphs into a full-width text
// field through a fluid gooey blob-merge transition rather than a plain
// resize.
//
// The goo illusion is the well-documented SVG-filter recipe (a Gaussian
// blur followed by a color-matrix threshold, referenced via `filter:
// url(#id)` on a dedicated "chrome" wrapper) — the same technique this
// package's `squigglyText.ts` already uses for its own SVG-filter chain, and
// not unique to any one UI library. `stdDeviation` is a literal-camelCase
// SVG presentation attribute (like `baseFrequency`/`numOctaves` on
// `<feTurbulence>`, which `squigglyText.ts` already required); it was
// missing from `packages/core/src/constants/CamelAttributes.ts` (confirmed
// by inspection — its typing already existed in `HtmlAttributeMap.ts`), so
// it would have been written to the DOM as `std-deviation`, an attribute the
// SVG filter spec doesn't recognize. Fixed at the source in this change
// (additive only, `stdDeviation` + `colorInterpolationFilters`), matching
// `squigglyText.ts`'s own documented precedent for the same class of gap.
//
// Geometry: the icon bubble stays pinned at the chrome group's origin; the
// pill box sits underneath/behind it at rest (same footprint, clipped by
// the collapsed chrome wrapper's own width so only a same-sized circular
// slice is visible — reading as one shape even before the filter kicks in),
// then animates its own `left`/`width` out to sit `offset` px clear of the
// icon and fill `expandedWidth` px. Both the chrome wrapper's width and the
// pill box's left/width are driven by `motion()` with a `State<MotionKeyframe>`
// (this package's `layoutTextFlip.ts` badge-width idiom) so a single State
// write re-triggers a WAAPI tween on all three properties in lockstep. The
// blur/color-matrix filter is applied to the chrome group ONLY while a
// transition is in flight (`isTransitioning`) — per the spec's own
// performance guidance, removed once the shapes have settled at rest. The
// real `<input>` text sits OUTSIDE the filtered chrome group as a sibling
// layered on top, so glyphs stay crisp and unaffected by the blur.

import type { DomphyElement, ElementNode, Listener, StyleObject } from "@domphy/core";
import { hashString, toState, watch } from "@domphy/core";
import type { MotionKeyframe } from "@domphy/ui";
import { motion } from "@domphy/ui";
import { themeColor, themeDensity, themeSize, themeSpacing } from "@domphy/theme";

export interface GooeyInputProps {
  /** Placeholder text shown once expanded. Defaults to `"Search…"`. */
  placeholder?: string;
  /** Initial/controlled text content. Defaults to `""`. */
  value?: string;
  /** Collapsed pill/circle width, in px. Defaults to `115`. */
  collapsedWidth?: number;
  /** Expanded text-field width, in px. Defaults to `200`. */
  expandedWidth?: number;
  /** Gap, in px, between the detached icon bubble and the expanded field once open. Defaults to `50`. */
  offset?: number;
  /** Gaussian blur `stdDeviation` driving the goo merge — higher reads gooier. Defaults to `5`. */
  blurStrength?: number;
  /** Fires with the live text value as the user types. */
  onValueChange?: (value: string) => void;
  /** Fires whenever the expanded/collapsed state toggles. */
  onOpenChange?: (open: boolean) => void;
  /** Disables the trigger and field entirely. Defaults to `false`. */
  disabled?: boolean;
  /** Extra class name merged onto the outer wrapper's native `class` attribute. */
  className?: string;
  /** Extra class name merged onto the icon bubble button. */
  iconClassName?: string;
  /** Extra class name merged onto the pill/field box. */
  boxClassName?: string;
  /** Passthrough style merged onto the outer wrapper. */
  style?: StyleObject;
}

const ICON_DIAMETER_PX = 44;
const TRANSITION_DURATION_MS = 380;
const TRANSITION_EASING = "cubic-bezier(0.16, 1, 0.3, 1)";

// Visually-hidden but screen-reader-visible label text, matching
// `canvasText.ts`'s own `SR_ONLY_STYLE` idiom.
const SR_ONLY_STYLE = {
  position: "absolute",
  width: "1px",
  height: "1px",
  padding: "0",
  margin: "-1px",
  overflow: "hidden",
  clip: "rect(0, 0, 0, 0)",
  whiteSpace: "nowrap",
  border: "0",
} as const;

let gooeyInputInstanceCounter = 0;

/** Magnifying-glass glyph, hand-composed from a circle + a diagonal handle line — not traced from any icon library. */
function searchGlyph(): DomphyElement<"span"> {
  return {
    span: [
      {
        svg: [
          { circle: null, cx: "10", cy: "10", r: "6", fill: "none", stroke: "currentColor", strokeWidth: "2" } as DomphyElement,
          { line: null, x1: "14.5", y1: "14.5", x2: "20", y2: "20", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" } as DomphyElement,
        ],
        viewBox: "0 0 24 24",
        fill: "none",
        role: "img",
        ariaHidden: "true",
        style: { width: "48%", height: "48%" },
      } as DomphyElement<"svg">,
    ],
    style: { display: "inline-flex", alignItems: "center", justifyContent: "center", width: "100%", height: "100%" } as StyleObject,
  };
}

/** Hidden SVG holding the blur + color-matrix "goo" filter chain, referenced via `filter: url(#id)`. */
function gooFilterDefs(filterId: string, blurStrength: number): DomphyElement<"svg"> {
  return {
    svg: [
      {
        defs: [
          {
            filter: [
              {
                feGaussianBlur: null,
                in: "SourceGraphic",
                stdDeviation: String(blurStrength),
                colorInterpolationFilters: "sRGB",
                result: "goo-blur",
              } as DomphyElement,
              {
                feColorMatrix: null,
                in: "goo-blur",
                type: "matrix",
                values: "1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 18 -7",
                result: "goo-threshold",
              } as DomphyElement,
              { feBlend: null, in: "SourceGraphic", in2: "goo-threshold" } as DomphyElement,
            ],
            id: filterId,
          } as DomphyElement,
        ],
      } as DomphyElement,
    ],
    ariaHidden: "true",
    _key: "goo-filter-defs",
    style: { position: "absolute", width: 0, height: 0, overflow: "hidden" } as StyleObject,
  } as DomphyElement<"svg">;
}

/**
 * A compact icon-sized search trigger that morphs into a full-width text
 * field via a fluid gooey blob-merge transition. Call with no arguments for
 * a working demo.
 */
function gooeyInput(props: GooeyInputProps = {}): DomphyElement<"div"> {
  const instanceId = ++gooeyInputInstanceCounter;
  const placeholder = props.placeholder ?? "Search…";
  const collapsedWidth = Math.max(ICON_DIAMETER_PX, props.collapsedWidth ?? 115);
  const expandedWidth = Math.max(collapsedWidth, props.expandedWidth ?? 200);
  const offset = Math.max(0, props.offset ?? 50);
  const blurStrength = Math.max(0, props.blurStrength ?? 5);
  const disabled = props.disabled ?? false;
  const onValueChange = props.onValueChange;
  const onOpenChange = props.onOpenChange;

  const filterId = `domphy-gooey-input-${instanceId}-${hashString(String(blurStrength))}`;
  const inputId = `domphy-gooey-input-field-${instanceId}`;
  const totalCollapsedWidth = ICON_DIAMETER_PX;
  const totalExpandedWidth = ICON_DIAMETER_PX + offset + expandedWidth;

  const value = toState(props.value ?? "");
  const isOpen = toState(false);
  const isTransitioning = toState(false);

  const chromeWidth = toState<MotionKeyframe>({ width: `${totalCollapsedWidth}px` });
  const pillGeometry = toState<MotionKeyframe>({ left: "0px", width: `${collapsedWidth}px` });

  let wrapperDomElement: HTMLElement | null = null;
  let inputDomElement: HTMLInputElement | null = null;
  let transitionSettleTimer: ReturnType<typeof setTimeout> | null = null;

  function setOpen(nextOpen: boolean): void {
    if (disabled || isOpen.get() === nextOpen) return;
    isOpen.set(nextOpen);
    onOpenChange?.(nextOpen);
  }

  function toggle(): void {
    setOpen(!isOpen.get());
  }

  const srOnlyLabel: DomphyElement<"label"> = {
    label: placeholder,
    for: inputId,
    style: SR_ONLY_STYLE as StyleObject,
  };

  const inputElement: DomphyElement<"input"> = {
    input: null,
    type: "text",
    id: inputId,
    placeholder,
    disabled,
    tabindex: (listener: Listener) => (isOpen.get(listener) ? 0 : -1),
    value: (listener: Listener) => value.get(listener),
    onInput: (event: Event) => {
      const nextValue = (event.target as HTMLInputElement).value;
      value.set(nextValue);
      onValueChange?.(nextValue);
    },
    onKeyDown: (event: KeyboardEvent) => {
      if (event.key === "Escape") setOpen(false);
    },
    _onMount: (node: ElementNode) => {
      inputDomElement = node.domElement as HTMLInputElement;
    },
    style: {
      position: "absolute",
      top: 0,
      left: `${ICON_DIAMETER_PX + offset}px`,
      height: `${ICON_DIAMETER_PX}px`,
      width: `${expandedWidth}px`,
      zIndex: 2,
      border: "none",
      outline: "none",
      background: "transparent",
      fontSize: (listener: Listener) => themeSize(listener, "inherit"),
      color: (listener: Listener) => themeColor(listener, "shift-10", "neutral"),
      paddingInline: (listener: Listener) => themeSpacing(themeDensity(listener) * 4),
      opacity: (listener: Listener) => (isOpen.get(listener) ? 1 : 0),
      pointerEvents: (listener: Listener) => (isOpen.get(listener) ? "auto" : "none"),
      transition: `opacity ${TRANSITION_DURATION_MS}ms ${TRANSITION_EASING}`,
    } as StyleObject,
  };

  const iconBubble: DomphyElement<"button"> = {
    button: [searchGlyph()],
    type: "button",
    disabled,
    ariaLabel: (listener: Listener) => (isOpen.get(listener) ? "Close search" : "Open search"),
    ariaExpanded: (listener: Listener) => isOpen.get(listener),
    class: props.iconClassName,
    onClick: toggle,
    dataTone: "shift-16",
    style: {
      position: "absolute",
      left: 0,
      top: 0,
      zIndex: 3,
      appearance: "none",
      border: "none",
      cursor: disabled ? "not-allowed" : "pointer",
      width: `${ICON_DIAMETER_PX}px`,
      height: `${ICON_DIAMETER_PX}px`,
      borderRadius: "50%",
      display: "inline-flex",
      alignItems: "center",
      justifyContent: "center",
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
      color: (listener: Listener) => themeColor(listener, "shift-9"),
      opacity: disabled ? 0.6 : 1,
      "&[disabled]": { cursor: "not-allowed" },
    } as StyleObject,
  };

  const pillBox: DomphyElement<"div"> = {
    div: null,
    ariaHidden: "true",
    class: props.boxClassName,
    dataTone: "shift-16",
    style: {
      position: "absolute",
      top: 0,
      // Static baseline matching `pillGeometry`'s initial value — `motion()`
      // still fires an implicit-keyframe animate() on mount, but animating
      // from this value to the identical target is a visual no-op, so there
      // is no flash-from-unstyled before the first real toggle (mirrors
      // `layoutTextFlip.ts`'s own "write the first value directly" fix for
      // the same WAAPI-on-mount behavior).
      left: 0,
      width: `${collapsedWidth}px`,
      height: `${ICON_DIAMETER_PX}px`,
      borderRadius: themeSpacing(999),
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
      color: (listener: Listener) => themeColor(listener, "shift-9"),
    } as StyleObject,
    $: [motion({ animate: pillGeometry, transition: { duration: TRANSITION_DURATION_MS, easing: TRANSITION_EASING } })],
  };

  const chromeGroup: DomphyElement<"div"> = {
    div: [pillBox, iconBubble],
    ariaHidden: "true",
    style: {
      position: "relative",
      // Static baseline matching `chromeWidth`'s initial value — see the
      // note on `pillBox` above.
      width: `${totalCollapsedWidth}px`,
      height: `${ICON_DIAMETER_PX}px`,
      overflow: "hidden",
      filter: (listener: Listener) => (isTransitioning.get(listener) ? `url(#${filterId})` : "none"),
    } as StyleObject,
    $: [motion({ animate: chromeWidth, transition: { duration: TRANSITION_DURATION_MS, easing: TRANSITION_EASING } })],
  };

  function applyTransition(open: boolean): void {
    isTransitioning.set(true);
    // `chromeGroup` itself grows to exactly the pill's expanded right edge
    // (see its own width formula), so its permanent `overflow: hidden`
    // needs no toggling — collapsed, it clips the wider-at-rest pill down
    // to the icon's own footprint; expanded, nothing is left to clip.
    chromeWidth.set({ width: `${open ? totalExpandedWidth : totalCollapsedWidth}px` });
    pillGeometry.set(
      open
        ? { left: `${ICON_DIAMETER_PX + offset}px`, width: `${expandedWidth}px` }
        : { left: "0px", width: `${collapsedWidth}px` },
    );

    if (transitionSettleTimer) clearTimeout(transitionSettleTimer);
    transitionSettleTimer = setTimeout(() => {
      isTransitioning.set(false);
      transitionSettleTimer = null;
    }, TRANSITION_DURATION_MS + 80);

    if (open) {
      setTimeout(() => inputDomElement?.focus(), TRANSITION_DURATION_MS * 0.6);
    } else {
      inputDomElement?.blur();
    }
  }

  return {
    div: [srOnlyLabel, gooFilterDefs(filterId, blurStrength), chromeGroup, inputElement],
    class: props.className,
    style: {
      position: "relative",
      display: "inline-block",
      height: `${ICON_DIAMETER_PX}px`,
      width: `${totalExpandedWidth}px`,
      ...(props.style ?? {}),
    } as StyleObject,
    _onMount: (node: ElementNode) => {
      wrapperDomElement = node.domElement as HTMLElement;
      if (typeof window === "undefined") return;

      const stopWatch = watch(isOpen, (open) => applyTransition(open));

      const handlePointerDown = (event: PointerEvent) => {
        if (!isOpen.get() || !wrapperDomElement) return;
        if (!wrapperDomElement.contains(event.target as Node)) setOpen(false);
      };
      const handleKeyDown = (event: KeyboardEvent) => {
        if (event.key === "Escape" && isOpen.get()) setOpen(false);
      };
      document.addEventListener("pointerdown", handlePointerDown);
      document.addEventListener("keydown", handleKeyDown);

      node.addHook("Remove", () => {
        stopWatch();
        document.removeEventListener("pointerdown", handlePointerDown);
        document.removeEventListener("keydown", handleKeyDown);
        if (transitionSettleTimer) clearTimeout(transitionSettleTimer);
      });
    },
  };
}

export { gooeyInput };

← Back to Aceternity UI catalog