shadimage
roiui
itemai-chatsource: ok · hit

AI Composer

An AI chat input component with mode selector and action buttons.

npx shadcn add "https://roiui.com/r/ai-chat.json"
free

Where it goes

chat

Interaction

dropdown

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

https://roiui.com/r/button.jsonhttps://roiui.com/r/card.jsonhttps://roiui.com/r/field.jsonhttps://roiui.com/r/form.jsonhttps://roiui.com/r/select.json

Files2 · source cached 10/9/2026, 5:31:57 AM

registry/brook/blocks/ai-chat/components/ai-chat.tsx
"use client";

import { ArrowUp, Paperclip } from "lucide-react";
import { useState } from "react";
import { Button } from "../../ui/button/button";
import { Card, CardContent, CardFooter } from "../../ui/card/card";
import { Field, FieldControl } from "../../ui/field/field";
import { Form } from "../../ui/form/form";
import {
  Select,
  SelectIcon,
  SelectItem,
  SelectItemIndicator,
  SelectItemText,
  SelectList,
  SelectPopup,
  SelectPortal,
  SelectPositioner,
  SelectSpacer,
  SelectTrigger,
  SelectValue,
} from "../../ui/select/select";
import styles from "./ai-chat.module.css";

const aiModes = [
  { value: "creative", label: "Creative" },
  { value: "fast", label: "Fast" },
  { value: "reasoning", label: "Reason" },
  { value: "teach", label: "Teach" },
];

export type AiChatProps = {
  onSubmit?: (submission: { message: string; mode: string }) => void | Promise<void>;
  onAttach?: () => void;
};

export function AiChat({ onSubmit, onAttach }: AiChatProps = {}) {
  const [message, setMessage] = useState("");
  const [isPending, setPending] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const hasContent = message.trim().length > 0;
  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    if (!hasContent || isPending) {
      return;
    }
    const mode = String(new FormData(event.currentTarget).get("mode") ?? aiModes[0].value);
    setPending(true);
    setError(null);
    try {
      if (onSubmit) {
        await onSubmit({ message: message.trim(), mode });
      }
      setMessage("");
    } catch {
      setError("Message could not be sent. Please try again.");
    } finally {
      setPending(false);
    }
  };

  return (
    <Form className={styles.form} onSubmit={handleSubmit}>
      <Card className={styles.aiChatCard}>
        <CardContent>
          <Field className={styles.field}>
            <FieldControl
              placeholder="How can I help…"
              render={
                <textarea
                  aria-label="Message"
                  className={styles.textarea}
                  disabled={isPending}
                  name="message"
                  onChange={(e) => setMessage(e.target.value)}
                  value={message}
                />
              }
            />
          </Field>
        </CardContent>
        <CardFooter className={styles.footer}>
          {onAttach ? (
            <Button
              aria-label="Attach file"
              className={styles.attachButton}
              disabled={isPending}
              onClick={onAttach}
              size="icon"
              type="button"
              variant="ghost"
            >
              <Paperclip size={14} />
            </Button>
          ) : null}

          <div className={styles.footerActions}>
            <Select defaultValue={aiModes[0].value} disabled={isPending} items={aiModes} name="mode">
              <SelectTrigger
                className={styles.selectTrigger}
                render={<Button className={styles.selectButton} size="sm" variant="ghost" />}
              >
                <SelectValue>
                  {(value) => {
                    const selectedMode = aiModes.find((mode) => mode.value === value);
                    return <span className={styles.selectValue}>{selectedMode?.label}</span>;
                  }}
                </SelectValue>
                <SelectIcon className={styles.selectIcon} />
              </SelectTrigger>
              <SelectPortal>
                <SelectPositioner align="start" alignItemWithTrigger={false} side="top" sideOffset={8}>
                  <SelectPopup className={styles.popup} data-slot="select-popup">
                    <SelectSpacer />
                    <SelectList>
                      {aiModes.map(({ label, value }) => (
                        <SelectItem data-slot="select-item" key={value} value={value}>
                          <SelectItemText>{label}</SelectItemText>
                          <SelectItemIndicator className={styles.selectIndicator} />
                        </SelectItem>
                      ))}
                    </SelectList>
                    <SelectSpacer />
                  </SelectPopup>
                </SelectPositioner>
              </SelectPortal>
            </Select>

            <Button
              aria-label="Send message"
              className={styles.submitButton}
              disabled={isPending || !hasContent}
              size="icon"
              type="submit"
              variant="ghost"
            >
              <ArrowUp className={styles.submitIcon} size={16} />
            </Button>
          </div>
        </CardFooter>
        {error ? <p role="alert">{error}</p> : null}
      </Card>
    </Form>
  );
}
registry/brook/blocks/ai-chat/components/ai-chat.module.css
.aiChatCard[data-slot="card"] {
  padding: 0.75rem;
  gap: 0.75rem;
  width: 100%;
  height: auto;
  max-width: 600px;
  min-width: 0;
  margin: 0 auto;
  border-radius: var(--radius-lg);
  border-color: oklch(from var(--border) l c h / 0.25);
  background-color: var(--mix-card-50-bg);
  transition: border-color 0.2s ease;

  &:hover,
  &:focus-within {
    border-color: oklch(from var(--border) l c h / 0.5);
  }
}

.form {
  width: 100%;
}

.textarea {
  background-color: transparent;
  border: none;
  padding: 0.25rem;
  resize: none;
  min-height: 2.5rem;
  height: auto;
  line-height: 1.5;

  &:focus {
    outline: none;
  }
}

.attachButton[data-slot="button"] {
  width: 32px;
  height: 32px;
  padding: 8px;
  border-radius: 50%;
  flex-shrink: 0;

  & svg {
    color: var(--muted-foreground);
    flex-shrink: 0;
    transform: rotate(-45deg);
  }
}

.footerActions {
  margin-left: auto;
  display: flex;
  gap: 0.5rem;
  align-items: center;
}

.selectButton[data-slot="button"] {
  border-radius: var(--radius);
}

.selectValue {
  color: var(--muted-foreground);
}

.selectIcon {
  margin-left: 0.25rem;
}

.selectIndicator {
  color: var(--muted-foreground);
}

.submitButton[data-slot="button"] {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  background-color: var(--primary);
  flex-shrink: 0;
}

.submitButton[data-slot="button"]:hover:not([data-disabled]) {
  background-color: oklch(from var(--primary) calc(l * 0.8) c h);
}

.submitIcon {
  color: var(--primary-foreground);
}

.footer[data-slot="card-footer"] {
  gap: 0.5rem;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.selectButton:hover,
.selectButton[data-popup-open] {
  background-color: var(--accent) !important;
}

.popup[data-slot="select-popup"] {
  box-sizing: border-box;
  min-width: 140px;
}

@media (max-width: 768px) {
  .popup[data-slot="select-popup"] {
    width: 120px !important;
    min-width: 120px !important;
    max-width: 120px !important;
  }

  .selectValue {
    font-size: 0.875rem;
  }

  .selectIcon {
    display: none;
  }
}

@media (max-width: 1280px) {
  .textarea {
    min-height: 4rem;
    padding: 0.5rem;
  }
}

@media (max-width: 640px) {
  .aiChatCard[data-slot="card"] {
    min-width: 230px;
  }

  .form {
    display: flex;
    justify-content: center;
    align-items: center;
  }
}

More from roiui

Browse all components in this library →