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
npm i mihirmodi-ui motionThe 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
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
| Prop | Type | Notes |
|---|---|---|
| labelReactNode | ReactNode | What the closed pill says. |
| onSubmit(value) => void | (value) => void | Called on send. The pill clears and closes after. |
| placeholder / hintstring / ReactNode | string / ReactNode | Passed to the composer. |
| iconReactNode | ReactNode | Before the label. Defaults to a spark. |
| widthnumber | number= 560 | Width 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.
"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>
);
}
"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>
);
});