← All components

ChatPill

A glass pill that squashes and stretches open into a composer where it stands.

The idea

An invitation you can leave on a page that is about something else, without a text field sitting there asking to be filled. Pressed, it grows out of its own bottom edge on the two disagreeing springs from DropMenu. Escape or a press outside closes it and keeps what you typed — losing a half-written question to a stray click is the one thing it must not do.

Composes ChatComposer and DropMenu.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

ChatPill — usage10 lines
import { ChatPill } from "mihirmodi-ui";

// Sticky at the bottom of a dashboard. It owns its own open state and text.
<div className="sticky bottom-6 flex justify-center">
  <ChatPill
    label="Ask about the business"
    placeholder="Pricing, retention, the pipeline…"
    onSubmit={(text) => router.push(`/sessions/new?q=${encodeURIComponent(text)}`)}
  />
</div>

Props

PropNotes
labelReactNodeWhat the closed pill says.
onSubmit(value) => voidCalled on send. The pill clears and closes after.
placeholder / hintstring / ReactNodePassed to the composer.
iconReactNodeBefore the label. Defaults to a spark.
widthnumberWidth of the open composer, capped at the viewport.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/ChatPill.tsx122 lines
"use client";

import { useEffect, useRef, useState, type ReactNode } from "react";
import { AnimatePresence, LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { ChatComposer } from "./ChatComposer.js";
import { SendIcon, SparkleIcon } from "../lib/glyph.js";
import { CLOSE, CROSS, GROW } from "../lib/motion.js";

export type ChatPillProps = {
  /** What the closed pill says. */
  label: ReactNode;
  onSubmit: (value: string) => void;
  placeholder?: string;
  hint?: ReactNode;
  /** A mark before the label. Defaults to a spark. */
  icon?: ReactNode;
  /** Width of the open composer. It is capped at the viewport either way. */
  width?: number;
  className?: string;
};

/**
 * A small glass pill that opens into a composer where it stands.
 *
 * The pill is an invitation you can leave on a page that is about something
 * else — a dashboard, a document — without a text field sitting there asking to
 * be filled. Pressed, it grows into the composer out of its own bottom edge,
 * with the two springs from `DropMenu`, so it squashes and stretches into shape
 * rather than being replaced.
 *
 * Escape, a press outside, or sending closes it. Sending also clears it; closing
 * without sending keeps what you typed, because losing a half-written question
 * to a stray click is the one thing this must not do.
 */
export function ChatPill({
  label,
  onSubmit,
  placeholder,
  hint = "Enter to send · Esc to close",
  icon,
  width = 560,
  className = "",
}: ChatPillProps) {
  const reduce = useReducedMotion();
  const [open, setOpen] = useState(false);
  const [value, setValue] = useState("");
  const field = useRef<HTMLTextAreaElement>(null);
  const root = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
    const onDown = (e: PointerEvent) => {
      if (root.current && !root.current.contains(e.target as Node)) setOpen(false);
    };
    window.addEventListener("keydown", onKey);
    window.addEventListener("pointerdown", onDown);
    return () => {
      window.removeEventListener("keydown", onKey);
      window.removeEventListener("pointerdown", onDown);
    };
  }, [open]);

  return (
    <LazyMotion features={domAnimation} strict>
      <div ref={root} className={`relative ${className}`}>
        <AnimatePresence mode="wait" initial={false}>
          {open ? (
            <m.div
              key="composer"
              className="origin-bottom"
              style={{ width: `min(${width}px, calc(100vw - 32px))` }}
              initial={reduce ? { opacity: 0 } : { opacity: 0, scaleY: 0.82, scaleX: 0.94, y: 4 }}
              animate={reduce ? { opacity: 1 } : { opacity: 1, scaleY: 1, scaleX: 1, y: 0 }}
              exit={reduce ? { opacity: 0, transition: CLOSE } : { opacity: 0, scaleY: 0.9, scaleX: 0.97, y: 4, transition: CLOSE }}
              transition={
                reduce
                  ? { duration: 0.15 }
                  : { scaleY: GROW, y: GROW, scaleX: CROSS, opacity: { duration: 0.14, ease: "easeOut" } }
              }
            >
              <ChatComposer
                ref={field}
                // The composer mounts only after the pill's exit finishes, so
                // an effect on `open` would run before there is a field to focus.
                autoFocus
                value={value}
                onChange={setValue}
                onSubmit={(v) => {
                  onSubmit(v);
                  setValue("");
                  setOpen(false);
                }}
                placeholder={placeholder}
                hint={hint}
              />
            </m.div>
          ) : (
            <m.button
              key="pill"
              type="button"
              onClick={() => setOpen(true)}
              aria-haspopup="dialog"
              aria-expanded={false}
              className="glass-nav dock-shadow flex h-12 items-center gap-3 rounded-2xl border-[0.5px] border-gray-alpha-400 pl-4 pr-1.5 text-gray-1000 transition-transform duration-150 ease-geist active:scale-[0.97]"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0, transition: CLOSE }}
              transition={{ duration: 0.16, ease: "easeOut" }}
            >
              <span className="text-quiet">{icon ?? <SparkleIcon className="h-4 w-4" />}</span>
              <span className="whitespace-nowrap text-[14px] text-gray-900">{label}</span>
              <span className="flex h-9 w-9 items-center justify-center rounded-[11px] bg-ink text-background-100">
                <SendIcon />
              </span>
            </m.button>
          )}
        </AnimatePresence>
      </div>
    </LazyMotion>
  );
}
packages/ui/src/components/ChatComposer.tsx119 lines
"use client";

import { forwardRef, type ReactNode } from "react";
import { SendIcon } from "../lib/glyph.js";

export type ChatComposerProps = {
  value: string;
  onChange: (value: string) => void;
  /** Enter sends; Shift+Enter is a new line. Not called for an empty or whitespace-only value. */
  onSubmit: (value: string) => void;
  placeholder?: string;
  /** The small line bottom-left — "Enter to send", a scope note. */
  hint?: ReactNode;
  /** While true the field is disabled and the placeholder says so. */
  busy?: boolean;
  busyText?: string;
  /** Controls to the left of the hint — an attach button. */
  tools?: ReactNode;
  /** Above the field — attachment chips, an upload zone. */
  header?: ReactNode;
  /**
   * `glass` floats over content that scrolls under it — the bottom of a
   * transcript. `plain` sits on a surface of its own, inside a panel.
   */
  surface?: "glass" | "plain";
  rows?: number;
  /** Focus the field when it mounts — for a composer that appears on demand. */
  autoFocus?: boolean;
  /** Extra keys, before Enter is handled. Call `preventDefault()` to claim one. */
  onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
  className?: string;
  "aria-label"?: string;
};

/**
 * The box you type a message into.
 *
 * It is a form with a real submit button, so Enter-to-send is a convenience on
 * top of something that already works — a screen reader, a password manager or
 * a mobile keyboard's "go" key all find the button. The send button is ink when
 * there is something to send and a quiet grey when there is not, so it says
 * whether pressing it will do anything before you try.
 */
export const ChatComposer = forwardRef<HTMLTextAreaElement, ChatComposerProps>(function ChatComposer(
  {
    value,
    onChange,
    onSubmit,
    placeholder = "Ask anything",
    hint = "Enter to send",
    busy = false,
    busyText = "Working…",
    tools,
    header,
    surface = "glass",
    rows = 2,
    autoFocus,
    onKeyDown,
    className = "",
    "aria-label": ariaLabel = "Message",
  },
  ref
) {
  const can = value.trim().length > 0 && !busy;
  const send = () => can && onSubmit(value.trim());

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        send();
      }}
      className={className}
    >
      <div
        className={`rounded-2xl border-[0.5px] border-gray-alpha-400 p-1.5 ${
          surface === "glass" ? "glass-nav dock-shadow" : "bg-background-100"
        }`}
      >
        {header && <div className="px-1.5 pt-1.5">{header}</div>}
        <textarea
          ref={ref}
          rows={rows}
          autoFocus={autoFocus}
          value={value}
          onChange={(e) => onChange(e.target.value)}
          onKeyDown={(e) => {
            onKeyDown?.(e);
            if (e.defaultPrevented) return;
            if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
              e.preventDefault();
              send();
            }
          }}
          placeholder={busy ? busyText : placeholder}
          disabled={busy}
          aria-label={ariaLabel}
          className="w-full resize-none bg-transparent px-3 pb-1 pt-2.5 text-[14px] leading-[1.5] text-gray-1000 outline-none placeholder:text-faint disabled:opacity-60"
        />
        <div className="flex items-center justify-between gap-2 px-1.5 pb-0.5">
          <div className="flex min-w-0 items-center gap-2">
            {tools}
            {hint && <span className="truncate text-[12px] text-faint">{hint}</span>}
          </div>
          <button
            type="submit"
            disabled={!can}
            aria-label="Send"
            className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-[10px] transition-[background-color,color,transform] duration-150 ease-geist active:scale-[0.92] ${
              can ? "bg-ink text-background-100" : "bg-gray-200 text-faint"
            }`}
          >
            <SendIcon />
          </button>
        </div>
      </div>
    </form>
  );
});