# Chat Basic

> A chat basic block.

A shadcn/ui block from the [@blocks-so](https://blocks.so) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/blocks-so/chat-01).

- Page, with a live preview: https://shadcn.exchange/r/blocks-so/chat-01
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/blocks-so/chat-01-light-ba9ca2e08bbe64ce.png
- Registry item: https://blocks.so/r/chat-01.json
- Categories: chat
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://blocks.so/r/chat-01.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@tabler/icons-react`
- Registry items: `bubble`, `button`, `dropdown-menu`, `input-group`, `message`, `message-scroller`, `textarea`

## Files

### `content/components/chat/chat-01.tsx`

```tsx
'use client';

import {
  IconArrowUp,
  IconCopy,
  IconMicrophone,
  IconPaperclip,
  IconPhoto,
  IconPlayerStopFilled,
  IconPlus,
  IconRefresh,
  IconThumbDown,
  IconThumbUp,
  IconWorldSearch,
} from '@tabler/icons-react';
import type React from 'react';
import { useState } from 'react';
import { Bubble, BubbleContent } from '@/components/ui/bubble';
import { Button } from '@/components/ui/button';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from '@/components/ui/input-group';
import {
  Message,
  MessageContent,
  MessageFooter,
} from '@/components/ui/message';
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from '@/components/ui/message-scroller';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';

type Turn = {
  id: string;
  role: 'user' | 'assistant';
  text: string;
  streaming?: boolean;
};

const thread: Turn[] = [
  {
    id: '1',
    role: 'user',
    text: 'Is it true that a day on Venus is longer than its year?',
  },
  {
    id: '2',
    role: 'assistant',
    text: 'Yes. Venus takes 243 Earth days to rotate once, but only 225 days to orbit the Sun, so its day is longer than its year.\n\nIt also spins in the opposite direction to most planets, which means the Sun rises in the west there.',
  },
  { id: '3', role: 'user', text: 'How hot does it get on the surface?' },
  {
    id: '4',
    role: 'assistant',
    text: 'The surface sits around 465°C, hot enough to melt lead. The atmosphere traps so much heat that Venus stays hotter than Mercury, even though Mercury is much closer to the Sun.',
  },
];

const replies = [
  'Good question. Venus has no moons and no rings, which makes it unusual among the inner planets. Most explanations point to its slow, retrograde spin: any moon it once had would have drifted inward and been pulled apart by tidal forces.\n\nMercury is the only other moonless planet in the solar system.',
  'The clouds are mostly sulfuric acid droplets, sitting about 50 to 70 km above the surface. They reflect roughly 75% of incoming sunlight, which is why Venus is the brightest object in our sky after the Moon.\n\nBelow the clouds the air is almost entirely carbon dioxide, with pressure about 92 times that of Earth at sea level.',
  'Several landers have made it down. The Soviet Venera 7 was the first to transmit from the surface in 1970, and Venera 13 survived for 127 minutes in 1982, long enough to send back the first color photos.\n\nNothing has lasted more than a couple of hours; the heat and pressure destroy electronics quickly.',
];

const thinkDelay = 600;
const wordDelay = 35;

const easeOut = 'ease-[cubic-bezier(0.23,1,0.32,1)]';
const press = `${easeOut} transition-[scale,background-color] duration-150 active:scale-[0.96]`;
const enter = `${easeOut} motion-reduce:slide-in-from-bottom-0 fade-in slide-in-from-bottom-1 animate-in duration-200`;
const swap = `${easeOut} fade-in zoom-in-95 animate-in duration-150`;

export default function Chat01() {
  const [turns, setTurns] = useState(thread);
  const [draft, setDraft] = useState('');
  const [listening, setListening] = useState(false);
  const [replyIndex, setReplyIndex] = useState(0);
  const [edit, setEdit] = useState<{ id: string; text: string } | null>(null);
  const streaming = turns.some((t) => t.streaming);

  const stream = (replyId: string) => {
    const words = replies[replyIndex % replies.length].split(' ');
    setReplyIndex(replyIndex + 1);
    setTurns((prev) =>
      prev.map((t) =>
        t.id === replyId ? { ...t, text: '', streaming: true } : t
      )
    );
    let shown = 0;
    setTimeout(() => {
      const timer = setInterval(() => {
        shown += 1;
        const done = shown >= words.length;
        setTurns((prev) =>
          prev.map((t) =>
            t.id === replyId
              ? {
                  ...t,
                  text: words.slice(0, shown).join(' '),
                  streaming: !done,
                }
              : t
          )
        );
        if (done) {
          clearInterval(timer);
        }
      }, wordDelay);
    }, thinkDelay);
  };

  const send = (e: React.FormEvent) => {
    e.preventDefault();
    const text = draft.trim();
    if (!text || streaming) {
      return;
    }
    const replyId = crypto.randomUUID();
    setTurns([
      ...turns,
      { id: crypto.randomUUID(), role: 'user', text },
      { id: replyId, role: 'assistant', text: '' },
    ]);
    setDraft('');
    stream(replyId);
  };

  const resend = () => {
    const text = edit?.text.trim();
    if (!(edit && text) || streaming) {
      return;
    }
    const at = turns.findIndex((t) => t.id === edit.id);
    const replyId = crypto.randomUUID();
    setTurns([
      ...turns.slice(0, at),
      { id: edit.id, role: 'user', text },
      { id: replyId, role: 'assistant', text: '' },
    ]);
    setEdit(null);
    stream(replyId);
  };

  const editLast = (e: React.KeyboardEvent) => {
    const last = turns.findLast((t) => t.role === 'user');
    if (e.key === 'ArrowUp' && !draft && last) {
      e.preventDefault();
      setEdit({ id: last.id, text: last.text });
    }
  };

  const body = (turn: Turn) => {
    const mine = turn.role === 'user';
    if (edit?.id === turn.id) {
      return (
        <>
          <Textarea
            aria-label="Edit message"
            autoFocus
            className={cn(
              enter,
              'min-h-0 w-md max-w-full resize-none rounded-[22px] border-0 bg-muted px-4 py-3 font-medium text-[15px]/6 focus-visible:ring-0 md:text-[15px]/6'
            )}
            onChange={(e) => setEdit({ ...edit, text: e.target.value })}
            onKeyDown={(e) => {
              if (e.key === 'Escape') {
                setEdit(null);
              }
              if (e.key === 'Enter' && !e.shiftKey) {
                e.preventDefault();
                resend();
              }
            }}
            value={edit.text}
          />
          <div className={cn(enter, 'flex items-center gap-1.5 self-end')}>
            <Button
              aria-label="Copy"
              className={cn(press, 'rounded-full')}
              onClick={() => navigator.clipboard.writeText(turn.text)}
              size="icon-sm"
              variant="ghost"
            >
              <IconCopy stroke={1.6} />
            </Button>
            <Button
              className={cn(press, 'rounded-full')}
              onClick={() => setEdit(null)}
              size="sm"
              variant="secondary"
            >
              Cancel
            </Button>
            <Button
              className={cn(press, 'rounded-full')}
              disabled={!edit.text.trim() || streaming}
              onClick={resend}
              size="sm"
            >
              Send
            </Button>
          </div>
        </>
      );
    }
    if (turn.streaming && !turn.text) {
      return (
        <span className="shimmer text-[15px]/6 text-muted-foreground">
          Thinking…
        </span>
      );
    }
    return turn.text.split('\n\n').map((paragraph) => (
      <Bubble
        align={mine ? 'end' : 'start'}
        className={
          mine
            ? '-mx-2.5 -my-1 box-content max-w-md cursor-pointer rounded-xl px-2.5 py-1 text-right font-medium transition-colors duration-150 ease-out hover:bg-muted'
            : undefined
        }
        key={paragraph}
        onDoubleClick={
          mine ? () => setEdit({ id: turn.id, text: turn.text }) : undefined
        }
        title={mine ? 'Double-click to edit' : undefined}
        variant="ghost"
      >
        <BubbleContent className="text-[15px]/6">{paragraph}</BubbleContent>
      </Bubble>
    ));
  };

  return (
    <div className="flex h-dvh w-full flex-col bg-background">
      <MessageScrollerProvider autoScroll>
        <MessageScroller>
          <MessageScrollerViewport>
            <MessageScrollerContent className="mx-auto w-full max-w-2xl gap-3.5 px-1.5 pt-12 pb-4">
              {turns.map((turn) => {
                const mine = turn.role === 'user';
                return (
                  <MessageScrollerItem
                    className={cn(
                      'px-2.5',
                      mine && 'pt-5.5 pb-1 first:pt-1',
                      !thread.some((t) => t.id === turn.id) && enter
                    )}
                    key={turn.id}
                    messageId={turn.id}
                  >
                    <Message align={mine ? 'end' : 'start'}>
                      <MessageContent className="gap-2">
                        {body(turn)}
                        {!(mine || turn.streaming) && (
                          <MessageFooter className={cn(enter, 'gap-0.5')}>
                            <Button
                              aria-label="Copy"
                              className={press}
                              onClick={() =>
                                navigator.clipboard.writeText(turn.text)
                              }
                              size="icon-sm"
                              variant="ghost"
                            >
                              <IconCopy stroke={1.6} />
                            </Button>
                            <Button
                              aria-label="Good response"
                              className={press}
                              size="icon-sm"
                              variant="ghost"
                            >
                              <IconThumbUp stroke={1.6} />
                            </Button>
                            <Button
                              aria-label="Bad response"
                              className={press}
                              size="icon-sm"
                              variant="ghost"
                            >
                              <IconThumbDown stroke={1.6} />
                            </Button>
                            <Button
                              aria-label="Regenerate"
                              className={press}
                              onClick={() => stream(turn.id)}
                              size="icon-sm"
                              variant="ghost"
                            >
                              <IconRefresh stroke={1.6} />
                            </Button>
                          </MessageFooter>
                        )}
                      </MessageContent>
                    </Message>
                  </MessageScrollerItem>
                );
              })}
            </MessageScrollerContent>
          </MessageScrollerViewport>
          <MessageScrollerButton className={easeOut} />
        </MessageScroller>
      </MessageScrollerProvider>

      <form
        className="mx-auto flex w-full max-w-2xl flex-col items-center gap-2.5 px-4 pt-3 pb-4"
        onSubmit={send}
      >
        <InputGroup className="h-13 rounded-full border-0 bg-muted px-2.5 has-[[data-slot=input-group-control]:focus-visible]:ring-0">
          <InputGroupAddon>
            <DropdownMenu>
              <DropdownMenuTrigger
                render={
                  <InputGroupButton
                    aria-label="Add attachment"
                    className={cn(press, 'rounded-full hover:bg-foreground/10')}
                    size="icon-sm"
                  />
                }
              >
                <IconPlus stroke={1.8} />
              </DropdownMenuTrigger>
              <DropdownMenuContent
                align="start"
                className="w-60 rounded-[20px] p-2 shadow-[0_0_0_1px_oklch(0_0_0/0.06),0_8px_24px_-6px_oklch(0_0_0/0.14)]"
                sideOffset={10}
              >
                <DropdownMenuGroup className="flex flex-col gap-0.5">
                  <DropdownMenuItem className="gap-3 rounded-xl px-3 py-2.5 text-[15px] [&>svg]:text-muted-foreground">
                    <IconPaperclip className="size-4.5" stroke={1.6} />
                    Add files
                  </DropdownMenuItem>
                  <DropdownMenuItem className="gap-3 rounded-xl px-3 py-2.5 text-[15px] [&>svg]:text-muted-foreground">
                    <IconPhoto className="size-4.5" stroke={1.6} />
                    Add photos
                  </DropdownMenuItem>
                  <DropdownMenuItem className="gap-3 rounded-xl px-3 py-2.5 text-[15px] [&>svg]:text-muted-foreground">
                    <IconWorldSearch className="size-4.5" stroke={1.6} />
                    Search the web
                  </DropdownMenuItem>
                </DropdownMenuGroup>
              </DropdownMenuContent>
            </DropdownMenu>
          </InputGroupAddon>
          <InputGroupInput
            className="text-[15px] md:text-[15px]"
            onChange={(e) => setDraft(e.target.value)}
            onKeyDown={editLast}
            placeholder={listening ? 'Listening…' : 'Ask anything'}
            value={draft}
          />
          <InputGroupAddon align="inline-end">
            {draft.trim() ? (
              <InputGroupButton
                aria-label="Send"
                className={cn(swap, press, 'rounded-full')}
                disabled={streaming}
                size="icon-sm"
                type="submit"
                variant="default"
              >
                <IconArrowUp stroke={2} />
              </InputGroupButton>
            ) : (
              <InputGroupButton
                aria-label={listening ? 'Stop listening' : 'Voice input'}
                aria-pressed={listening}
                className={cn(
                  swap,
                  press,
                  'rounded-full',
                  listening
                    ? 'bg-destructive/15 text-destructive hover:bg-destructive/25'
                    : 'hover:bg-foreground/10'
                )}
                onClick={() => setListening(!listening)}
                size="icon-sm"
              >
                {listening ? (
                  <IconPlayerStopFilled className="animate-pulse motion-reduce:animate-none" />
                ) : (
                  <IconMicrophone stroke={1.8} />
                )}
              </InputGroupButton>
            )}
          </InputGroupAddon>
        </InputGroup>
        <p className="text-muted-foreground text-xs">
          AI can make mistakes. Check important info.
        </p>
      </form>
    </div>
  );
}
```
