For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Composer

Composable chat input parts built on Input Group, with a pill shell, toolbars, a footer, and a working state.

For the assembled version with menus and a status row, see Composer 01.

Installation

Set up the @cobalt registry first, as described in Installation, because Composer depends on this registry's input-group. Then add the component:

$ pnpm dlx shadcn@latest add @cobalt/composer

Composer uses your theme's semantic tokens. The preview above shows it with the Cobalt theme, which you can add separately:

$ pnpm dlx shadcn@latest add @cobalt/theme-cobalt

Usage

<Composer onSend={(message) => send(message)} working={isWorking}>
  <ComposerThinking />
  <ComposerShell>
    <ComposerInput aria-label="Message" placeholder="Write a message..." />
    <ComposerToolbar>
      <ComposerSend />
    </ComposerToolbar>
  </ComposerShell>
  <ComposerFooter>Press Enter to send</ComposerFooter>
</Composer>

Behavior

  • onSend receives the trimmed message when the form is submitted with the send button or Enter. Empty messages are ignored.
  • Sending clears the field. Use value and onValueChange to control it yourself.
  • While working is true, sending is disabled, ComposerThinking shows an elapsed timer, and the shell shows the loading band.
  • Put your own controls in ComposerToolbar, using InputGroupButton with shape="pill" and size="icon".

Source

"use client";

import { RiArrowUpLine } from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";

import { AgentThinking } from "@/components/ui/agent-thinking";
import { ComposerLoader } from "@/components/ui/composer-loader";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from "@/components/ui/input-group";

interface ComposerContextValue {
  setValue: (value: string) => void;
  value: string;
  working: boolean;
}

const ComposerContext = React.createContext<ComposerContextValue | null>(null);

function useComposer() {
  const context = React.useContext(ComposerContext);
  if (!context) {
    throw new Error("Composer parts must be used inside <Composer>.");
  }
  return context;
}

function Composer({
  className,
  value: valueProp,
  defaultValue = "",
  onValueChange,
  onSend,
  working = false,
  children,
  ...props
}: Omit<React.ComponentProps<"form">, "defaultValue" | "onSubmit"> & {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  onSend?: (message: string) => void;
  working?: boolean;
}) {
  const [internalValue, setInternalValue] = React.useState(defaultValue);
  const value = valueProp ?? internalValue;

  const setValue = React.useCallback(
    (next: string) => {
      setInternalValue(next);
      onValueChange?.(next);
    },
    [onValueChange]
  );

  const context = React.useMemo(
    () => ({ setValue, value, working }),
    [setValue, value, working]
  );

  return (
    <ComposerContext.Provider value={context}>
      <form
        data-slot="composer"
        data-state={working ? "working" : "idle"}
        className={cn("group/composer flex w-full flex-col gap-2", className)}
        onSubmit={(event) => {
          event.preventDefault();
          const message = value.trim();
          if (!message || working) {
            return;
          }
          onSend?.(message);
          setValue("");
        }}
        {...props}
      >
        {children}
      </form>
    </ComposerContext.Provider>
  );
}

/**
 * The status line above the shell while the agent works: an `AgentThinking`
 * row (indicator, shimmering label, elapsed timer). It mounts with `working`,
 * so the timer restarts on every send.
 */
function ComposerThinking({
  className,
  children = "Working",
  label,
  ...props
}: Omit<React.ComponentProps<typeof AgentThinking>, "label" | "children"> & {
  children?: string;
  label?: string;
}) {
  const { working } = useComposer();

  if (!working) {
    return null;
  }

  return (
    <AgentThinking
      label={label ?? children}
      className={cn(
        "px-3 duration-300 animate-in fade-in-0 motion-reduce:animate-none",
        className
      )}
      {...props}
    />
  );
}

function ComposerShell({
  className,
  children,
  shape = "pill",
  ...props
}: React.ComponentProps<typeof InputGroup>) {
  const { working } = useComposer();

  return (
    <InputGroup
      data-slot="composer-shell"
      shape={shape}
      className={cn(
        // No blue focus ring: a neutral, subtle ring keeps keyboard focus visible.
        "has-[[data-slot=input-group-control]:focus-visible]:ring-1 has-[[data-slot=input-group-control]:focus-visible]:ring-foreground/15 group-data-[state=working]/composer:[&_[data-slot=button][data-size=icon]:not([type=submit])]:border-foreground/10 group-data-[state=working]/composer:[&_[data-slot=button][data-size=icon]:not([type=submit])]:bg-background/40 group-data-[state=working]/composer:[&_[data-slot=button][data-size=icon]:not([type=submit])]:backdrop-blur-md",
        className
      )}
      {...props}
    >
      <ComposerLoader
        active={working}
        radius={shape === "pill" ? undefined : 10}
      />
      {children}
    </InputGroup>
  );
}

function ComposerInput({
  className,
  onChange,
  ...props
}: Omit<React.ComponentProps<typeof InputGroupInput>, "value">) {
  const { setValue, value } = useComposer();

  return (
    <InputGroupInput
      autoComplete="off"
      className={cn(
        "relative z-1 h-full px-2 placeholder:text-muted-foreground/70 focus:placeholder:text-muted-foreground/70 md:text-sm",
        className
      )}
      value={value}
      onChange={(event) => {
        onChange?.(event);
        setValue(event.target.value);
      }}
      {...props}
    />
  );
}

function ComposerToolbar({
  className,
  align = "inline-end",
  ...props
}: Omit<React.ComponentProps<typeof InputGroupAddon>, "align"> & {
  align?: "inline-start" | "inline-end";
}) {
  return (
    <InputGroupAddon
      data-slot="composer-toolbar"
      align={align}
      className={cn("relative z-1 gap-2 px-1 has-[>button]:mx-0", className)}
      {...props}
    />
  );
}

function ComposerSend({
  children,
  className,
  disabled,
  "aria-label": ariaLabel = "Send message",
  ...props
}: Omit<React.ComponentProps<typeof InputGroupButton>, "type">) {
  const { working } = useComposer();

  return (
    <InputGroupButton
      aria-label={ariaLabel}
      className={cn(
        "[&:disabled,&[aria-disabled=true]]:bg-[image:var(--gradient-button-primary-default,linear-gradient(180deg,oklch(from_var(--primary)_calc(l_+_0.08)_c_h),var(--primary)))] [&:disabled,&[aria-disabled=true]]:text-primary-foreground [&:disabled,&[aria-disabled=true]]:opacity-50",
        className
      )}
      disabled={disabled || working}
      shape="pill"
      size="icon"
      type="submit"
      variant="default"
      {...props}
    >
      {children ?? <RiArrowUpLine className="size-5" />}
    </InputGroupButton>
  );
}

function ComposerFooter({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="composer-footer"
      className={cn(
        "flex min-w-0 flex-wrap items-center justify-between gap-2 px-0.5",
        className
      )}
      {...props}
    />
  );
}

export {
  Composer,
  ComposerFooter,
  ComposerInput,
  ComposerLoader,
  ComposerSend,
  ComposerShell,
  ComposerThinking,
  ComposerToolbar,
};