10.3k

Changelog

PreviousNext

Latest updates and announcements.

July 2026 - Rhea & Components for Chat Interfaces

Two big additions this month: a new style, Rhea, and a set of components for building chat interfaces.

Rhea

Say hello to Rhea, a new style for shadcn-vue.

Rhea is the latest shadcn/ui look, mirroring the upstream base-rhea style as closely as possible. It keeps the rounded, soft aesthetic you already know but refines the details: denser surfaces, ring-based focus states, and subtle press interactions on buttons. Built for focused product interfaces.

Rhea is opt-in. The default style stays the same, so nothing changes for your existing projects. Head to the customizer and pick Rhea from the style switcher to preview it, then copy the theme into your app.

Chat interface components

We're also releasing a new set of components for building chat interfaces: Message Scroller, Message, Bubble, Attachment, and Marker. Plus two new CSS utilities to round them out.

Message Scroller

The scroll container for a conversation. It handles the parts that are easy to get wrong: anchored turns, streamed replies, saved thread restore, prepended history, jump-to-message, scroll controls, and visibility tracking.

Streaming Messages
Auto-scroll follows the live edge of the conversation.
Ready to Stream

Press send to stream a scripted launch summary.

I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.
Streaming is simulated. `autoScroll` is enabled.
<script setup lang="ts">
import {
  ArrowUpIcon,
  GlobeIcon,
  ImageIcon,
  MessageCircleDashedIcon,
  PaperclipIcon,
  PlusIcon,
  RotateCwIcon,
  TelescopeIcon,
} from '@lucide/vue'
import { computed } from 'vue'
import { createDemoChat, useDemoChat } from '@/lib/message-scroller-demo'
import { Button } from '@/components/ui/button'
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from '@/components/ui/empty'
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
} from '@/components/ui/input-group'
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from '@/components/ui/message-scroller'
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from '@/components/ui/tooltip'

const chat = createDemoChat()
  .user('I\'m building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.')
  .assistant('That\'s the classic streaming scroll problem. Wrap your message list in `MessageScroller` and turn on `autoScroll` — the viewport pins to the bottom as tokens arrive, so users always see the latest text land in place.\n\nThe important part: it only auto-scrolls while the reader is already at the bottom. The moment they scroll up to read something earlier, auto-scroll backs off and their position is preserved. You get smooth streaming without fighting the user\'s intent.')
  .user('Okay, but when someone sends a new message the view still feels jarring — like the whole conversation reloads from the top.')
  .assistant('MessageScrollerItem fixes that with turn anchoring. Set `scrollAnchor` on the turn that should settle near the top instead of blindly snapping to the document bottom.\n\nIt also leaves a small peek of the previous exchange visible above the anchor, so context isn\'t lost. The reply starts in view without that disorienting jump you get from a plain overflow container.')
  .user('And if they\'ve scrolled up to re-read an older answer? I don\'t want to yank them back down.')
  .assistant('You won\'t. Auto-scroll only runs when the viewport is already pinned to the bottom, so scrolling up is a deliberate opt-out — their place in the thread stays put even as new tokens keep arriving below.\n\nWhen there is content they haven\'t seen yet, `MessageScrollerButton` appears at the bottom of the viewport. One tap jumps them back to the newest message and re-engages auto-scroll. Same pattern as Slack or iMessage: quiet when you\'re caught up, helpful when you\'re not.')
  .user('Last one — does this work with assistive tech?')
  .assistant('`MessageScrollerContent` sets `role="log"` and `aria-relevant="additions"` by default, so screen readers announce new messages as they stream in.\n\nThe scroll button is a real `<button>` with an sr-only label, and it\'s removed from the tab order when you\'re already at the bottom — no ghost focus stops.')

const { messages, status, nextMessage, send, reset } = useDemoChat(chat, { chunkDelayMs: 20 })

const isBusy = computed(() => status.value === 'streaming')

function onSubmit() {
  if (!nextMessage.value || isBusy.value)
    return
  send()
}
</script>

<template>
  <MessageScrollerProvider auto-scroll>
    <div class="relative flex flex-col gap-4">
      <Card class="mx-auto h-140 w-full max-w-sm gap-0">
        <CardHeader class="gap-1 border-b">
          <CardTitle>Streaming Messages</CardTitle>
          <CardDescription>
            Auto-scroll follows the live edge of the conversation.
          </CardDescription>
          <CardAction>
            <Tooltip>
              <TooltipTrigger as-child>
                <Button
                  variant="outline"
                  size="icon"
                  aria-label="Reset stream"
                  :disabled="messages.length === 0 || isBusy"
                  @click="reset"
                >
                  <RotateCwIcon />
                </Button>
              </TooltipTrigger>
              <TooltipContent>
                <p>Reset</p>
              </TooltipContent>
            </Tooltip>
          </CardAction>
        </CardHeader>
        <CardContent class="flex-1 overflow-hidden p-0">
          <Empty v-if="messages.length === 0" class="h-full">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <MessageCircleDashedIcon />
              </EmptyMedia>
              <EmptyTitle>Ready to Stream</EmptyTitle>
              <EmptyDescription>
                Press send to stream a scripted launch summary.
              </EmptyDescription>
            </EmptyHeader>
          </Empty>
          <MessageScroller v-else>
            <MessageScrollerViewport>
              <MessageScrollerContent :aria-busy="isBusy" class="p-6">
                <MessageAnimated
                  v-for="message in messages"
                  :key="message.id"
                  :message="message"
                  :scroll-anchor="message.role === 'user'"
                />
              </MessageScrollerContent>
            </MessageScrollerViewport>
            <MessageScrollerButton />
          </MessageScroller>
        </CardContent>
        <CardFooter class="flex-col gap-2">
          <form class="w-full" @submit.prevent="onSubmit">
            <InputGroup>
              <div class="h-14 w-full px-3 py-2.5">
                <span
                  class="line-clamp-2 opacity-60 data-[status=ready]:opacity-100"
                  :data-status="status"
                >
                  <template v-if="nextMessage">{{ nextMessage.text }}</template>
                  <span v-else class="text-muted-foreground">
                    No messages queued. Reset the stream.
                  </span>
                </span>
              </div>
              <InputGroupAddon align="block-end" class="pt-1">
                <DropdownMenu>
                  <DropdownMenuTrigger as-child>
                    <InputGroupButton
                      aria-label="Add files"
                      type="button"
                      size="icon-sm"
                      variant="outline"
                    >
                      <PlusIcon />
                    </InputGroupButton>
                  </DropdownMenuTrigger>
                  <DropdownMenuContent align="start" side="top" class="w-44">
                    <DropdownMenuItem>
                      <PaperclipIcon />
                      Add Photos &amp; Files
                    </DropdownMenuItem>
                    <DropdownMenuSeparator />
                    <DropdownMenuItem>
                      <ImageIcon />
                      Create Image
                    </DropdownMenuItem>
                    <DropdownMenuItem>
                      <TelescopeIcon />
                      Deep Research
                    </DropdownMenuItem>
                    <DropdownMenuItem>
                      <GlobeIcon />
                      Web Search
                    </DropdownMenuItem>
                  </DropdownMenuContent>
                </DropdownMenu>
                <InputGroupButton
                  type="submit"
                  variant="default"
                  size="icon-sm"
                  class="ml-auto"
                  :disabled="!nextMessage || isBusy"
                >
                  <ArrowUpIcon />
                  <span class="sr-only">Send</span>
                </InputGroupButton>
              </InputGroupAddon>
            </InputGroup>
          </form>
        </CardFooter>
      </Card>
      <div class="px-0.5 text-center text-xs text-muted-foreground">
        Streaming is simulated. `autoScroll` is enabled.
      </div>
    </div>
  </MessageScrollerProvider>
</template>

Message

Message lays out a row in the conversation with avatar, alignment, header, content, footer, and grouped messages.

ME
Deploying to prod real quick.
R
It's 4:55 PM. On a Friday.
ME
It's a one-line change.
Delivered
R
It's always a one-line change 😭.
Alright, let me take a look.
👍
Oliver is typing...
<script setup lang="ts">
import {
  Avatar,
  AvatarFallback,
  AvatarImage,
} from '@/components/ui/avatar'
import {
  Bubble,
  BubbleContent,
  BubbleGroup,
  BubbleReactions,
} from '@/components/ui/bubble'
import { Marker, MarkerContent } from '@/components/ui/marker'
import { Message, MessageAvatar, MessageContent, MessageFooter } from '@/components/ui/message'
</script>

<template>
  <div class="flex w-full max-w-sm flex-col gap-6 py-12">
    <Message align="end">
      <MessageAvatar>
        <Avatar>
          <AvatarImage src="/avatars/10.png" alt="@me" />
          <AvatarFallback>ME</AvatarFallback>
        </Avatar>
      </MessageAvatar>
      <MessageContent>
        <Bubble>
          <BubbleContent>Deploying to prod real quick.</BubbleContent>
        </Bubble>
      </MessageContent>
    </Message>
    <Message>
      <MessageAvatar>
        <Avatar>
          <AvatarImage src="/avatars/02.png" alt="@rabbit" />
          <AvatarFallback>R</AvatarFallback>
        </Avatar>
      </MessageAvatar>
      <MessageContent>
        <Bubble variant="muted">
          <BubbleContent>It&apos;s 4:55 PM. On a Friday.</BubbleContent>
        </Bubble>
      </MessageContent>
    </Message>
    <Message align="end">
      <MessageAvatar>
        <Avatar>
          <AvatarImage src="/avatars/10.png" alt="@me" />
          <AvatarFallback>ME</AvatarFallback>
        </Avatar>
      </MessageAvatar>
      <MessageContent>
        <Bubble>
          <BubbleContent>It&apos;s a one-line change.</BubbleContent>
        </Bubble>
        <MessageFooter>Delivered</MessageFooter>
      </MessageContent>
    </Message>
    <Message>
      <MessageAvatar>
        <Avatar>
          <AvatarImage src="/avatars/02.png" alt="@rabbit" />
          <AvatarFallback>R</AvatarFallback>
        </Avatar>
      </MessageAvatar>
      <MessageContent>
        <BubbleGroup>
          <Bubble variant="muted">
            <BubbleContent>
              It&apos;s always a one-line change 😭.
            </BubbleContent>
          </Bubble>
          <Bubble variant="muted">
            <BubbleContent>Alright, let me take a look.</BubbleContent>
            <BubbleReactions aria-label="Reactions: thumbs up">
              <span>👍</span>
            </BubbleReactions>
          </Bubble>
        </BubbleGroup>
      </MessageContent>
    </Message>
    <Marker role="status">
      <MarkerContent class="shimmer">
        <span class="font-medium">Oliver</span> is typing...
      </MarkerContent>
    </Marker>
  </div>
</template>

Bubble

Bubble renders the message surface, with variants, alignment, reactions, links, buttons, and collapsible content.

This is the default primary bubble.
This is the secondary variant.
This one is muted. It uses a lower emphasis color for the chat bubble.
This one is tinted. The tint is a softer color derived from the primary color.
We can also use an outlined variant.
Or a destructive variant with a reaction.
Ghost bubbles work for assistant text, markdown, and other content that should not be framed. This is perfect for assistant messages that should not have a frame and can take the full width of the container. You can also render code in it. Ghost bubbles are full width and can take the full width of the container.
<script setup lang="ts">
import { Bubble, BubbleContent, BubbleReactions } from '@/components/ui/bubble'
</script>

<template>
  <div class="flex w-full max-w-sm flex-col gap-12 py-12">
    <Bubble>
      <BubbleContent>This is the default primary bubble.</BubbleContent>
    </Bubble>
    <Bubble variant="secondary" align="end">
      <BubbleContent>This is the secondary variant.</BubbleContent>
    </Bubble>
    <Bubble variant="muted">
      <BubbleContent>
        This one is muted. It uses a lower emphasis color for the chat bubble.
      </BubbleContent>
      <BubbleReactions role="img" aria-label="Reaction: thumbs up">
        <span>👍</span>
      </BubbleReactions>
    </Bubble>
    <Bubble variant="tinted" align="end">
      <BubbleContent>
        This one is tinted. The tint is a softer color derived from the
        primary color.
      </BubbleContent>
    </Bubble>
    <Bubble variant="outline">
      <BubbleContent>We can also use an outlined variant.</BubbleContent>
    </Bubble>
    <Bubble variant="destructive" align="end">
      <BubbleContent>Or a destructive variant with a reaction.</BubbleContent>
      <BubbleReactions role="img" aria-label="Reaction: fire">
        <span>🔥</span>
      </BubbleReactions>
    </Bubble>
    <Bubble variant="ghost">
      <BubbleContent>
        Ghost bubbles work for assistant text, <b>markdown</b>, and other content that should not be framed.

        This is perfect for assistant messages that should not have a frame and can take the full width of the container. You can also render <code>code</code> in it.

        Ghost bubbles are full width and can take the full width of the container.
      </BubbleContent>
    </Bubble>
  </div>
</template>

Attachment

Attachment renders files and images with media, metadata, upload state, actions, and a full-card trigger.

Workspace
workspace.pngPNG · 820 KB
Desk
desk-reference.jpgJPG · 1.1 MB
Office
office-reference.jpgJPG · 940 KB
sales-dashboard.pdfUploading · 64%
message-renderer.vueTypeScript · 12 KB
<script setup lang="ts">
import { FileCodeIcon, XIcon } from '@lucide/vue'
import {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentGroup,
  AttachmentMedia,
  AttachmentTitle,
} from '@/components/ui/attachment'
import { Spinner } from '@/components/ui/spinner'

const images = [
  {
    name: 'workspace.png',
    meta: 'PNG · 820 KB',
    src: 'https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=900&auto=format&fit=crop&q=80',
    alt: 'Workspace',
  },
  {
    name: 'desk-reference.jpg',
    meta: 'JPG · 1.1 MB',
    src: 'https://images.unsplash.com/photo-1497215728101-856f4ea42174?w=900&auto=format&fit=crop&q=80',
    alt: 'Desk',
  },
  {
    name: 'office-reference.jpg',
    meta: 'JPG · 940 KB',
    src: 'https://images.unsplash.com/photo-1497366811353-6870744d04b2?w=900&auto=format&fit=crop&q=80',
    alt: 'Office',
  },
]
</script>

<template>
  <div class="mx-auto flex w-full max-w-sm flex-col gap-3 py-12">
    <AttachmentGroup>
      <template v-for="image in images" :key="image.name">
        <Attachment orientation="vertical">
          <AttachmentMedia variant="image">
            <img :src="image.src" :alt="image.alt">
          </AttachmentMedia>
          <AttachmentContent>
            <AttachmentTitle>{{ image.name }}</AttachmentTitle>
            <AttachmentDescription>{{ image.meta }}</AttachmentDescription>
          </AttachmentContent>
        </Attachment>
      </template>
    </AttachmentGroup>
    <Attachment state="uploading" class="w-full">
      <AttachmentMedia>
        <Spinner />
      </AttachmentMedia>
      <AttachmentContent>
        <AttachmentTitle>sales-dashboard.pdf</AttachmentTitle>
        <AttachmentDescription>Uploading · 64%</AttachmentDescription>
      </AttachmentContent>
      <AttachmentActions>
        <AttachmentAction aria-label="Cancel upload">
          <XIcon />
        </AttachmentAction>
      </AttachmentActions>
    </Attachment>
    <Attachment class="w-full">
      <AttachmentMedia>
        <FileCodeIcon />
      </AttachmentMedia>
      <AttachmentContent>
        <AttachmentTitle>message-renderer.vue</AttachmentTitle>
        <AttachmentDescription>TypeScript · 12 KB</AttachmentDescription>
      </AttachmentContent>
      <AttachmentActions>
        <AttachmentAction aria-label="Remove message-renderer.vue">
          <XIcon />
        </AttachmentAction>
      </AttachmentActions>
    </Attachment>
  </div>
</template>

Marker

Marker renders status updates, system notes, bordered rows, and labeled separators.

Switched to a new branch
Thinking...
Conversation compacted
Explored 4 files
<script setup lang="ts">
import { GitBranchIcon, SearchIcon } from '@lucide/vue'
import {
  Marker,
  MarkerContent,
  MarkerIcon,
} from '@/components/ui/marker'
import { Spinner } from '@/components/ui/spinner'
</script>

<template>
  <div class="flex w-full max-w-sm flex-col gap-8 py-12">
    <Marker>
      <MarkerIcon>
        <GitBranchIcon />
      </MarkerIcon>
      <MarkerContent>Switched to a new branch</MarkerContent>
    </Marker>
    <Marker role="status">
      <MarkerIcon>
        <Spinner />
      </MarkerIcon>
      <MarkerContent class="shimmer">
        Thinking...
      </MarkerContent>
    </Marker>
    <Marker variant="separator">
      <MarkerContent>Conversation compacted</MarkerContent>
    </Marker>
    <Marker>
      <MarkerIcon>
        <SearchIcon />
      </MarkerIcon>
      <MarkerContent>Explored 4 files</MarkerContent>
    </Marker>
  </div>
</template>

scroll-fade and shimmer

Two CSS utilities to finish off the set. scroll-fade dissolves content at the edges of a scroll container, and shimmer adds a shimmer effect to text, perfect for streaming and loading states. Both ship with the shadcn-vue package, so there's nothing to install after init.

Item 1
Item 2
Item 3
Item 4
Item 5
Item 6
Item 7
Item 8
Item 9
Item 10
Item 11
Item 12
<script setup lang="ts">
const items = Array.from({ length: 12 }, (_, index) => `Item ${index + 1}`)
</script>

<template>
  <div class="mx-auto w-full max-w-xs overflow-hidden rounded-2xl border">
    <div class="scroll-fade scrollbar-none h-72 overflow-y-auto">
      <div class="flex flex-col gap-1.5 p-1.5">
        <div
          v-for="item in items"
          :key="item"
          class="rounded-lg bg-muted px-3 py-2.5 text-sm"
        >
          {{ item }}
        </div>
      </div>
    </div>
  </div>
</template>

November 2025 - Catch up

We've upgraded shadcn-vue.com to Nuxt v4 and Tailwind v4. The site now uses the upgraded new-york components.

We've also made some minor design updates to make the site faster and easier to navigate.

Chart

We also refactored the Chart component to match the original component, and stick to the API as close as possible.

October 2025 - New Components

For this round of components, I looked at what we build every day, the boring stuff we rebuild over and over, and made reusable abstractions you can actually use.

  • Spinner: An indicator to show a loading state.
  • Kbd: Display a keyboard key or group of keys.
  • Button Group: A group of buttons for actions and split buttons.
  • Input Group: Input with icons, buttons, labels and more.
  • Field: One component. All your forms.
  • Item: Display lists of items, cards, and more.
  • Empty: Use this one for empty states.

Spinner

Okay let's start with the easiest ones: Spinner and Kbd. Pretty basic. We all know what they do.

Here's how you render a spinner:

<script setup lang="ts">
import { Spinner } from '@/components/ui/spinner'
</script>

<template>
  <Spinner />
</template>

Here's what it looks like:

<script setup lang="ts">
import { Spinner } from '@/components/ui/spinner'
</script>

<template>
  <div class="flex flex-col items-center justify-center gap-8">
    <Spinner />
  </div>
</template>

Here's what it looks like in a button:

<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner'
</script>

<template>
  <div class="flex flex-col items-center gap-4">
    <Button disabled size="sm">
      <Spinner />
      Loading...
    </Button>
    <Button variant="outline" disabled size="sm">
      <Spinner />
      Please wait
    </Button>
    <Button variant="secondary" disabled size="sm">
      <Spinner />
      Processing
    </Button>
  </div>
</template>

You can edit the code and replace it with your own spinner.

<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { LoaderIcon } from '@lucide/vue'
import { cn } from '@/lib/utils'

const props = defineProps<{
  class?: HTMLAttributes['class']
}>()
</script>

<template>
  <LoaderIcon
    role="status"
    aria-label="Loading"
    :class="cn('size-4 animate-spin', props.class)"
  />
</template>

Kbd

Kbd is a component that renders a keyboard key.

<script setup lang="ts">
import { Kbd, KbdGroup } from '@/components/ui/kbd'
</script>

<template>
  <Kbd>Ctrl</Kbd>
</template>

Use KbdGroup to group keyboard keys together.

<template>
  <KbdGroup>
    <Kbd>Ctrl</Kbd>
    <Kbd>B</Kbd>
  </KbdGroup>
</template>
Ctrl+B
<script lang='ts' setup>
import { Kbd, KbdGroup } from '@/components/ui/kbd'
</script>

<template>
  <div class="flex flex-col items-center gap-4">
    <KbdGroup>
      <Kbd>⌘</Kbd>
      <Kbd>⇧</Kbd>
      <Kbd>⌥</Kbd>
      <Kbd>⌃</Kbd>
    </KbdGroup>

    <KbdGroup>
      <Kbd>Ctrl</Kbd>
      <span>+</span>
      <Kbd>B</Kbd>
    </KbdGroup>
  </div>
</template>

You can add it to buttons, tooltips, input groups, and more.

Button Group

I got a lot of requests for this one: Button Group. It's a container that groups related buttons together with consistent styling. Great for action groups, split buttons, and more.

<script setup lang="ts">
import { ArchiveIcon, ArrowLeftIcon, CalendarPlusIcon, ClockIcon, ListFilterPlusIcon, MailCheckIcon, MoreHorizontalIcon, TagIcon, Trash2Icon } from '@lucide/vue'
import { Button } from '@/components/ui/button'
import { ButtonGroup } from '@/components/ui/button-group'
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'

const label = ref('personal')
</script>

<template>
  <ButtonGroup>
    <ButtonGroup class="hidden sm:flex">
      <Button variant="outline" size="icon" aria-label="Go Back">
        <ArrowLeftIcon />
      </Button>
    </ButtonGroup>
    <ButtonGroup>
      <Button variant="outline">
        Archive
      </Button>
      <Button variant="outline">
        Report
      </Button>
    </ButtonGroup>
    <ButtonGroup>
      <Button variant="outline">
        Snooze
      </Button>
      <DropdownMenu>
        <DropdownMenuTrigger as-child>
          <Button variant="outline" size="icon" aria-label="More Options">
            <MoreHorizontalIcon />
          </Button>
        </DropdownMenuTrigger>
        <DropdownMenuContent align="end" class="w-52">
          <DropdownMenuGroup>
            <DropdownMenuItem>
              <MailCheckIcon />
              Mark as Read
            </DropdownMenuItem>
            <DropdownMenuItem>
              <ArchiveIcon />
              Archive
            </DropdownMenuItem>
          </DropdownMenuGroup>
          <DropdownMenuSeparator />
          <DropdownMenuGroup>
            <DropdownMenuItem>
              <ClockIcon />
              Snooze
            </DropdownMenuItem>
            <DropdownMenuItem>
              <CalendarPlusIcon />
              Add to Calendar
            </DropdownMenuItem>
            <DropdownMenuItem>
              <ListFilterPlusIcon />
              Add to List
            </DropdownMenuItem>
            <DropdownMenuSub>
              <DropdownMenuSubTrigger>
                <TagIcon class="mr-2 size-4" />
                Label As...
              </DropdownMenuSubTrigger>
              <DropdownMenuSubContent>
                <DropdownMenuRadioGroup v-model="label">
                  <DropdownMenuRadioItem value="personal">
                    Personal
                  </DropdownMenuRadioItem>
                  <DropdownMenuRadioItem value="work">
                    Work
                  </DropdownMenuRadioItem>
                  <DropdownMenuRadioItem value="other">
                    Other
                  </DropdownMenuRadioItem>
                </DropdownMenuRadioGroup>
              </DropdownMenuSubContent>
            </DropdownMenuSub>
          </DropdownMenuGroup>
          <DropdownMenuSeparator />
          <DropdownMenuGroup>
            <DropdownMenuItem variant="destructive">
              <Trash2Icon />
              Trash
            </DropdownMenuItem>
          </DropdownMenuGroup>
        </DropdownMenuContent>
      </DropdownMenu>
    </ButtonGroup>
  </ButtonGroup>
</template>

Here's the code:

<script setup lang="ts">
import { ButtonGroup } from '@/components/ui/button-group'
</script>

<template>
  <ButtonGroup>
    <Button>Button 1</Button>
    <Button>Button 2</Button>
  </ButtonGroup>
</template>

You can nest button groups to create more complex layouts with spacing.

<template>
  <ButtonGroup>
    <ButtonGroup>
      <Button>Button 1</Button>
      <Button>Button 2</Button>
    </ButtonGroup>
    <ButtonGroup>
      <Button>Button 3</Button>
      <Button>Button 4</Button>
    </ButtonGroup>
  </ButtonGroup>
</template>

Use ButtonGroupSeparator to create split buttons. Classic dropdown pattern.

<script setup lang="ts">
import { AlertTriangleIcon, CheckIcon, ChevronDownIcon, CopyIcon, ShareIcon, TrashIcon, UserRoundXIcon, VolumeOffIcon } from '@lucide/vue'
import { Button } from '@/components/ui/button'
import { ButtonGroup } from '@/components/ui/button-group'
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
</script>

<template>
  <ButtonGroup>
    <Button variant="outline">
      Follow
    </Button>
    <DropdownMenu>
      <DropdownMenuTrigger as-child>
        <Button variant="outline" size="icon">
          <ChevronDownIcon />
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end" class="[--radius:1rem]">
        <DropdownMenuGroup>
          <DropdownMenuItem>
            <VolumeOffIcon />
            Mute Conversation
          </DropdownMenuItem>
          <DropdownMenuItem>
            <CheckIcon />
            Mark as Read
          </DropdownMenuItem>
          <DropdownMenuItem>
            <AlertTriangleIcon />
            Report Conversation
          </DropdownMenuItem>
          <DropdownMenuItem>
            <UserRoundXIcon />
            Block User
          </DropdownMenuItem>
          <DropdownMenuItem>
            <ShareIcon />
            Share Conversation
          </DropdownMenuItem>
          <DropdownMenuItem>
            <CopyIcon />
            Copy Conversation
          </DropdownMenuItem>
        </DropdownMenuGroup>
        <DropdownMenuSeparator />
        <DropdownMenuGroup>
          <DropdownMenuItem variant="destructive">
            <TrashIcon />
            Delete Conversation
          </DropdownMenuItem>
        </DropdownMenuGroup>
      </DropdownMenuContent>
    </DropdownMenu>
  </ButtonGroup>
</template>

You can also use it to add prefix or suffix buttons and text to inputs.

<script setup lang="ts">
import { ArrowRightIcon } from '@lucide/vue'
import { ref } from 'vue'
import { Button } from '@/components/ui/button'
import { ButtonGroup } from '@/components/ui/button-group'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'

const CURRENCIES = [
  {
    value: '$',
    label: 'US Dollar',
  },
  {
    value: '€',
    label: 'Euro',
  },
  {
    value: '£',
    label: 'British Pound',
  },
]
const currency = ref('$')
</script>

<template>
  <ButtonGroup>
    <ButtonGroup>
      <Select v-model="currency">
        <SelectTrigger class="font-mono w-14">
          {{ currency }}
        </SelectTrigger>
        <SelectContent class="min-w-24">
          <SelectItem v-for="item in CURRENCIES" :key="item.value" :value="item.value">
            {{ item.value }}
            <span class="text-muted-foreground">{{ item.label }}</span>
          </SelectItem>
        </SelectContent>
      </Select>
      <Input placeholder="10.00" pattern="[0-9]*" />
    </ButtonGroup>
    <ButtonGroup>
      <Button aria-label="Send" size="icon" variant="outline">
        <ArrowRightIcon />
      </Button>
    </ButtonGroup>
  </ButtonGroup>
</template>
<template>
  <ButtonGroup>
    <ButtonGroupText>Prefix</ButtonGroupText>
    <Input placeholder="Type something here..." />
    <Button>Button</Button>
  </ButtonGroup>
</template>

Input Group

Input Group lets you add icons, buttons, and more to your inputs. You know, all those little bits you always need around your inputs.

<script setup lang="ts">
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'
</script>

<template>
  <InputGroup>
    <InputGroupInput placeholder="Search..." />
    <InputGroupAddon>
      <SearchIcon />
    </InputGroupAddon>
  </InputGroup>
</template>

Here's a preview with icons:

<script setup lang="ts">
import { CheckIcon, CreditCardIcon, InfoIcon, MailIcon, SearchIcon, StarIcon } from '@lucide/vue'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'
</script>

<template>
  <div class="grid w-full max-w-sm gap-6">
    <InputGroup>
      <InputGroupInput placeholder="Search..." />
      <InputGroupAddon>
        <SearchIcon />
      </InputGroupAddon>
    </InputGroup>
    <InputGroup>
      <InputGroupInput type="email" placeholder="Enter your email" />
      <InputGroupAddon>
        <MailIcon />
      </InputGroupAddon>
    </InputGroup>
    <InputGroup>
      <InputGroupInput placeholder="Card number" />
      <InputGroupAddon>
        <CreditCardIcon />
      </InputGroupAddon>
      <InputGroupAddon align="inline-end">
        <CheckIcon />
      </InputGroupAddon>
    </InputGroup>
    <InputGroup>
      <InputGroupInput placeholder="Card number" />
      <InputGroupAddon align="inline-end">
        <StarIcon />
        <InfoIcon />
      </InputGroupAddon>
    </InputGroup>
  </div>
</template>

You can also add buttons to the input group.

https://
<script setup lang="ts">
import { CheckIcon, CopyIcon, InfoIcon, StarIcon } from '@lucide/vue'
import { useClipboard } from '@vueuse/core'
import { ref } from 'vue'
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'

const isFavorite = ref(false)
const source = ref('hello')
const { text, copy, copied, isSupported } = useClipboard({ source })
</script>

<template>
  <div class="grid w-full max-w-sm gap-6">
    <InputGroup>
      <InputGroupInput placeholder="https://x.com/shadcn" read-only />
      <InputGroupAddon align="inline-end">
        <InputGroupButton
          aria-label="Copy"
          title="Copy"
          size="icon-xs"
          @click="copy('https://x.com/shadcn')"
        >
          <CheckIcon v-if="!copied" />
          <CopyIcon v-if="copied" />
        </InputGroupButton>
      </InputGroupAddon>
    </InputGroup>
    <InputGroup class="[--radius:9999px]">
      <Popover>
        <PopoverTrigger as-child>
          <InputGroupAddon>
            <InputGroupButton variant="secondary" size="icon-xs">
              <InfoIcon />
            </InputGroupButton>
          </InputGroupAddon>
        </PopoverTrigger>
        <PopoverContent
          align="start"
          class="flex flex-col gap-1 text-sm rounded-xl"
        >
          <p class="font-medium">
            Your connection is not secure.
          </p>
          <p>You should not enter any sensitive information on this site.</p>
        </PopoverContent>
      </Popover>
      <InputGroupAddon class="text-muted-foreground pl-1.5">
        https://
      </InputGroupAddon>
      <InputGroupInput id="input-secure-19" />
      <InputGroupAddon align="inline-end">
        <InputGroupButton
          size="icon-xs"
          @click="isFavorite = !isFavorite"
        >
          <StarIcon
            data-favorite="{isFavorite}"
            class="data-[favorite=true]:fill-blue-600 data-[favorite=true]:stroke-blue-600"
          />
        </InputGroupButton>
      </InputGroupAddon>
    </InputGroup>
    <InputGroup>
      <InputGroupInput placeholder="Type to search..." />
      <InputGroupAddon align="inline-end">
        <InputGroupButton variant="secondary">
          Search
        </InputGroupButton>
      </InputGroupAddon>
    </InputGroup>
  </div>
</template>

Or text, labels, tooltips,...

$
USD
https://
.com
@company.com
120 characters left
<script setup lang="ts">
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText, InputGroupTextarea } from '@/components/ui/input-group'
</script>

<template>
  <div class="grid w-full max-w-sm gap-6">
    <InputGroup>
      <InputGroupAddon>
        <InputGroupText>$</InputGroupText>
      </InputGroupAddon>
      <InputGroupInput placeholder="0.00" />
      <InputGroupAddon align="inline-end">
        <InputGroupText>USD</InputGroupText>
      </InputGroupAddon>
    </InputGroup>
    <InputGroup>
      <InputGroupAddon>
        <InputGroupText>https://</InputGroupText>
      </InputGroupAddon>
      <InputGroupInput placeholder="example.com" class="!pl-0.5" />
      <InputGroupAddon align="inline-end">
        <InputGroupText>.com</InputGroupText>
      </InputGroupAddon>
    </InputGroup>
    <InputGroup>
      <InputGroupInput placeholder="Enter your username" />
      <InputGroupAddon align="inline-end">
        <InputGroupText>@company.com</InputGroupText>
      </InputGroupAddon>
    </InputGroup>
    <InputGroup>
      <InputGroupTextarea placeholder="Enter your message" />
      <InputGroupAddon align="block-end">
        <InputGroupText class="text-xs text-muted-foreground">
          120 characters left
        </InputGroupText>
      </InputGroupAddon>
    </InputGroup>
  </div>
</template>

It also works with textareas so you can build really complex components with lots of knobs and dials or yet another prompt form.

Line 1, Column 1
script.js
<script setup lang="ts">
import { BracesIcon, CopyIcon, CornerDownLeftIcon, RefreshCwIcon } from '@lucide/vue'
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupTextarea } from '@/components/ui/input-group'
</script>

<template>
  <div class="grid w-full max-w-md gap-4">
    <InputGroup>
      <InputGroupTextarea
        id="textarea-code-32"
        placeholder="console.log('Hello, world!');"
        class="min-h-[200px]"
      />
      <InputGroupAddon align="block-end" class="border-t">
        <InputGroupText>Line 1, Column 1</InputGroupText>
        <InputGroupButton size="sm" class="ml-auto" variant="default">
          Run <CornerDownLeftIcon />
        </InputGroupButton>
      </InputGroupAddon>
      <InputGroupAddon align="block-start" class="border-b">
        <InputGroupText class="font-mono font-medium">
          <BracesIcon />
          script.js
        </InputGroupText>
        <InputGroupButton class="ml-auto" size="icon-xs">
          <RefreshCwIcon />
        </InputGroupButton>
        <InputGroupButton variant="ghost" size="icon-xs">
          <CopyIcon />
        </InputGroupButton>
      </InputGroupAddon>
    </InputGroup>
  </div>
</template>

Oh here are some cool ones with spinners:

Saving...
Please wait...
<script setup lang="ts">
import { LoaderIcon } from '@lucide/vue'
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from '@/components/ui/input-group'
import { Spinner } from '@/components/ui/spinner'
</script>

<template>
  <div class="grid w-full max-w-sm gap-4">
    <InputGroup data-disabled>
      <InputGroupInput placeholder="Searching..." disabled />
      <InputGroupAddon align="inline-end">
        <Spinner />
      </InputGroupAddon>
    </InputGroup>
    <InputGroup data-disabled>
      <InputGroupInput placeholder="Processing..." disabled />
      <InputGroupAddon>
        <Spinner />
      </InputGroupAddon>
    </InputGroup>
    <InputGroup data-disabled>
      <InputGroupInput placeholder="Saving changes..." disabled />
      <InputGroupAddon align="inline-end">
        <InputGroupText>Saving...</InputGroupText>
        <Spinner />
      </InputGroupAddon>
    </InputGroup>
    <InputGroup data-disabled>
      <InputGroupInput placeholder="Refreshing data..." disabled />
      <InputGroupAddon>
        <LoaderIcon class="animate-spin" />
      </InputGroupAddon>
      <InputGroupAddon align="inline-end">
        <InputGroupText class="text-muted-foreground">
          Please wait...
        </InputGroupText>
      </InputGroupAddon>
    </InputGroup>
  </div>
</template>

Field

Introducing Field, a component for building really complex forms. The abstraction here is beautiful.

It took me a long time to get it right but I made it work with all your form libraries: Vee Validate, TanStack Form, Bring Your Own Form.

Here's a basic field with an input:

<script setup lang="ts">
import {
  Field,
  FieldDescription,
  FieldError,
  FieldLabel,
} from '@/components/ui/field'
</script>

<template>
  <Field>
    <FieldLabel html-for="username">
      Username
    </FieldLabel>
    <Input id="username" placeholder="Max Leiter" />
    <FieldDescription>
      Choose a unique username for your account.
    </FieldDescription>
  </Field>
</template>

Choose a unique username for your account.

Must be at least 8 characters long.

<script setup lang="ts">
import {
  Field,
  FieldDescription,
  FieldGroup,
  FieldLabel,
  FieldSet,
} from '@/components/ui/field'
import { Input } from '@/components/ui/input'
</script>

<template>
  <div class="w-full max-w-md">
    <FieldSet>
      <FieldGroup>
        <Field>
          <FieldLabel for="username">
            Username
          </FieldLabel>
          <Input id="username" type="text" placeholder="Max Leiter" />
          <FieldDescription>
            Choose a unique username for your account.
          </FieldDescription>
        </Field>
        <Field>
          <FieldLabel for="password">
            Password
          </FieldLabel>
          <FieldDescription>
            Must be at least 8 characters long.
          </FieldDescription>
          <Input id="password" type="password" placeholder="********" />
        </Field>
      </FieldGroup>
    </FieldSet>
  </div>
</template>

It works with all form controls. Inputs, textareas, selects, checkboxes, radios, switches, sliders, you name it. Here's a full example:

Payment Method

All transactions are secure and encrypted

Enter your 16-digit card number

Billing Address

The billing address associated with your payment method

<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
  Field,
  FieldDescription,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSeparator,
  FieldSet,
} from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
</script>

<template>
  <div class="w-full max-w-md">
    <form>
      <FieldGroup>
        <FieldSet>
          <FieldLegend>Payment Method</FieldLegend>
          <FieldDescription>
            All transactions are secure and encrypted
          </FieldDescription>
          <FieldGroup>
            <Field>
              <FieldLabel for="checkout-7j9-card-name-43j">
                Name on Card
              </FieldLabel>
              <Input
                id="checkout-7j9-card-name-43j"
                placeholder="Evil Rabbit"
                required
              />
            </Field>
            <Field>
              <FieldLabel for="checkout-7j9-card-number-uw1">
                Card Number
              </FieldLabel>
              <Input
                id="checkout-7j9-card-number-uw1"
                placeholder="1234 5678 9012 3456"
                required
              />
              <FieldDescription>
                Enter your 16-digit card number
              </FieldDescription>
            </Field>
            <div class="grid grid-cols-3 gap-4">
              <Field>
                <FieldLabel for="checkout-exp-month-ts6">
                  Month
                </FieldLabel>
                <Select default-value="">
                  <SelectTrigger id="checkout-exp-month-ts6">
                    <SelectValue placeholder="MM" />
                  </SelectTrigger>
                  <SelectContent>
                    <SelectItem value="01">
                      01
                    </SelectItem>
                    <SelectItem value="02">
                      02
                    </SelectItem>
                    <SelectItem value="03">
                      03
                    </SelectItem>
                    <SelectItem value="04">
                      04
                    </SelectItem>
                    <SelectItem value="05">
                      05
                    </SelectItem>
                    <SelectItem value="06">
                      06
                    </SelectItem>
                    <SelectItem value="07">
                      07
                    </SelectItem>
                    <SelectItem value="08">
                      08
                    </SelectItem>
                    <SelectItem value="09">
                      09
                    </SelectItem>
                    <SelectItem value="10">
                      10
                    </SelectItem>
                    <SelectItem value="11">
                      11
                    </SelectItem>
                    <SelectItem value="12">
                      12
                    </SelectItem>
                  </SelectContent>
                </Select>
              </Field>
              <Field>
                <FieldLabel for="checkout-7j9-exp-year-f59">
                  Year
                </FieldLabel>
                <Select default-value="">
                  <SelectTrigger id="checkout-7j9-exp-year-f59">
                    <SelectValue placeholder="YYYY" />
                  </SelectTrigger>
                  <SelectContent>
                    <SelectItem value="2024">
                      2024
                    </SelectItem>
                    <SelectItem value="2025">
                      2025
                    </SelectItem>
                    <SelectItem value="2026">
                      2026
                    </SelectItem>
                    <SelectItem value="2027">
                      2027
                    </SelectItem>
                    <SelectItem value="2028">
                      2028
                    </SelectItem>
                    <SelectItem value="2029">
                      2029
                    </SelectItem>
                  </SelectContent>
                </Select>
              </Field>
              <Field>
                <FieldLabel for="checkout-7j9-cvv">
                  CVV
                </FieldLabel>
                <Input id="checkout-7j9-cvv" placeholder="123" required />
              </Field>
            </div>
          </FieldGroup>
        </FieldSet>
        <FieldSeparator />
        <FieldSet>
          <FieldLegend>Billing Address</FieldLegend>
          <FieldDescription>
            The billing address associated with your payment method
          </FieldDescription>
          <FieldGroup>
            <Field orientation="horizontal">
              <Checkbox
                id="checkout-7j9-same-as-shipping-wgm"
                :default-value="true"
              />
              <FieldLabel
                for="checkout-7j9-same-as-shipping-wgm"
                class="font-normal"
              >
                Same as shipping address
              </FieldLabel>
            </Field>
          </FieldGroup>
        </FieldSet>
        <FieldSet>
          <FieldGroup>
            <Field>
              <FieldLabel for="checkout-7j9-optional-comments">
                Comments
              </FieldLabel>
              <Textarea
                id="checkout-7j9-optional-comments"
                placeholder="Add any additional comments"
                class="resize-none"
              />
            </Field>
          </FieldGroup>
        </FieldSet>
        <Field orientation="horizontal">
          <Button type="submit">
            Submit
          </Button>
          <Button variant="outline" type="button">
            Cancel
          </Button>
        </Field>
      </FieldGroup>
    </form>
  </div>
</template>

Here are some checkbox fields:

Show these items on the desktop

Select the items you want to show on the desktop.

Your Desktop & Documents folders are being synced with iCloud Drive. You can access them from other devices.

<script setup lang="ts">
import { Checkbox } from '@/components/ui/checkbox'
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSeparator,
  FieldSet,
} from '@/components/ui/field'
</script>

<template>
  <div class="w-full max-w-md">
    <FieldGroup>
      <FieldSet>
        <FieldLegend variant="label">
          Show these items on the desktop
        </FieldLegend>
        <FieldDescription>
          Select the items you want to show on the desktop.
        </FieldDescription>
        <FieldGroup class="gap-3">
          <Field orientation="horizontal">
            <Checkbox id="finder-pref-9k2-hard-disks-ljj" />
            <FieldLabel
              for="finder-pref-9k2-hard-disks-ljj"
              class="font-normal"
              :default-value="true"
            >
              Hard disks
            </FieldLabel>
          </Field>
          <Field orientation="horizontal">
            <Checkbox id="finder-pref-9k2-external-disks-1yg" />
            <FieldLabel
              for="finder-pref-9k2-external-disks-1yg"
              class="font-normal"
            >
              External disks
            </FieldLabel>
          </Field>
          <Field orientation="horizontal">
            <Checkbox id="finder-pref-9k2-cds-dvds-fzt" />
            <FieldLabel
              for="finder-pref-9k2-cds-dvds-fzt"
              class="font-normal"
            >
              CDs, DVDs, and iPods
            </FieldLabel>
          </Field>
          <Field orientation="horizontal">
            <Checkbox id="finder-pref-9k2-connected-servers-6l2" />
            <FieldLabel
              for="finder-pref-9k2-connected-servers-6l2"
              class="font-normal"
            >
              Connected servers
            </FieldLabel>
          </Field>
        </FieldGroup>
      </FieldSet>
      <FieldSeparator />
      <Field orientation="horizontal">
        <Checkbox id="finder-pref-9k2-sync-folders-nep" :default-value="true" />
        <FieldContent>
          <FieldLabel for="finder-pref-9k2-sync-folders-nep">
            Sync Desktop & Documents folders
          </FieldLabel>
          <FieldDescription>
            Your Desktop & Documents folders are being synced with iCloud
            Drive. You can access them from other devices.
          </FieldDescription>
        </FieldContent>
      </Field>
    </FieldGroup>
  </div>
</template>

You can group fields together using FieldGroup and FieldSet. Perfect for multi-section forms.

<template>
  <FieldSet>
    <FieldLegend />
    <FieldGroup>
      <Field />
      <Field />
    </FieldGroup>
  </FieldSet>
</template>
Address Information

We need your address to deliver your order.

<script setup lang="ts">
import {
  Field,
  FieldDescription,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSet,
} from '@/components/ui/field'
import { Input } from '@/components/ui/input'
</script>

<template>
  <div class="w-full max-w-md space-y-6">
    <FieldSet>
      <FieldLegend>Address Information</FieldLegend>
      <FieldDescription>
        We need your address to deliver your order.
      </FieldDescription>
      <FieldGroup>
        <Field>
          <FieldLabel for="street">
            Street Address
          </FieldLabel>
          <Input id="street" type="text" placeholder="123 Main St" />
        </Field>
        <div class="grid grid-cols-2 gap-4">
          <Field>
            <FieldLabel for="city">
              City
            </FieldLabel>
            <Input id="city" type="text" placeholder="New York" />
          </Field>
          <Field>
            <FieldLabel for="zip">
              Postal Code
            </FieldLabel>
            <Input id="zip" type="text" placeholder="90502" />
          </Field>
        </div>
      </FieldGroup>
    </FieldSet>
  </div>
</template>

Making it responsive is easy. Use orientation="responsive" and it switches between vertical and horizontal layouts based on container width. Done.

Profile

Fill in your profile information.

Provide your full name for identification

You can write your message here. Keep it short, preferably under 100 characters.

<script setup lang="ts">
import { Button } from '@/components/ui/button'
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSeparator,
  FieldSet,
} from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
</script>

<template>
  <div class="w-full max-w-4xl">
    <form>
      <FieldSet>
        <FieldLegend>Profile</FieldLegend>
        <FieldDescription>Fill in your profile information.</FieldDescription>
        <FieldSeparator />
        <FieldGroup>
          <Field orientation="responsive">
            <FieldContent>
              <FieldLabel for="name">
                Name
              </FieldLabel>
              <FieldDescription>
                Provide your full name for identification
              </FieldDescription>
            </FieldContent>
            <Input id="name" placeholder="Evil Rabbit" required />
          </Field>
          <FieldSeparator />
          <Field orientation="responsive">
            <FieldContent>
              <FieldLabel for="lastName">
                Message
              </FieldLabel>
              <FieldDescription>
                You can write your message here. Keep it short, preferably
                under 100 characters.
              </FieldDescription>
            </FieldContent>
            <Textarea
              id="message"
              placeholder="Hello, world!"
              required
              class="min-h-[100px] resize-none sm:min-w-[300px]"
            />
          </Field>
          <FieldSeparator />
          <Field orientation="responsive">
            <Button type="submit">
              Submit
            </Button>
            <Button type="button" variant="outline">
              Cancel
            </Button>
          </Field>
        </FieldGroup>
      </FieldSet>
    </form>
  </div>
</template>

Wait here's more. Wrap your fields in FieldLabel to create a selectable field group. Really easy. And it looks great.

Select the compute environment for your cluster.

<script setup lang="ts">
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldGroup,
  FieldLabel,
  FieldSet,
  FieldTitle,
} from '@/components/ui/field'
import {
  RadioGroup,
  RadioGroupItem,
} from '@/components/ui/radio-group'
</script>

<template>
  <div class="w-full max-w-md">
    <FieldGroup>
      <FieldSet>
        <FieldLabel for="compute-environment-p8w">
          Compute Environment
        </FieldLabel>
        <FieldDescription>
          Select the compute environment for your cluster.
        </FieldDescription>
        <RadioGroup default-value="kubernetes">
          <FieldLabel for="kubernetes-r2h">
            <Field orientation="horizontal">
              <FieldContent>
                <FieldTitle>Kubernetes</FieldTitle>
                <FieldDescription>
                  Run GPU workloads on a K8s configured cluster.
                </FieldDescription>
              </FieldContent>
              <RadioGroupItem id="kubernetes-r2h" value="kubernetes" />
            </Field>
          </FieldLabel>
          <FieldLabel for="vm-z4k">
            <Field orientation="horizontal">
              <FieldContent>
                <FieldTitle>Virtual Machine</FieldTitle>
                <FieldDescription>
                  Access a VM configured cluster to run GPU workloads.
                </FieldDescription>
              </FieldContent>
              <RadioGroupItem id="vm-z4k" value="vm" />
            </Field>
          </FieldLabel>
        </RadioGroup>
      </FieldSet>
    </FieldGroup>
  </div>
</template>

Item

This one is a straightforward flex container that can house nearly any type of content.

I've built this so many times that I decided to create a component for it. Now I use it all the time. I use it to display lists of items, cards, and more.

Here's a basic item:

<script setup lang="ts">
import {
  Item,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from '@/components/ui/item'
</script>

<template>
  <Item>
    <ItemMedia variant="icon">
      <HomeIcon />
    </ItemMedia>
    <ItemContent>
      <ItemTitle>Dashboard</ItemTitle>
      <ItemDescription>Overview of your account and activity.</ItemDescription>
    </ItemContent>
  </Item>
</template>
Basic Item

A simple item with title and description.

Your profile has been verified.
<script setup lang="ts">
import { BadgeCheckIcon, ChevronRightIcon } from '@lucide/vue'
import { Button } from '@/components/ui/button'
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from '@/components/ui/item'
</script>

<template>
  <div class="flex w-full max-w-md flex-col gap-6">
    <Item variant="outline">
      <ItemContent>
        <ItemTitle>Basic Item</ItemTitle>
        <ItemDescription>
          A simple item with title and description.
        </ItemDescription>
      </ItemContent>
      <ItemActions>
        <Button variant="outline" size="sm">
          Action
        </Button>
      </ItemActions>
    </Item>
    <Item variant="outline" size="sm" as-child>
      <a href="#">
        <ItemMedia>
          <BadgeCheckIcon class="size-5" />
        </ItemMedia>
        <ItemContent>
          <ItemTitle>Your profile has been verified.</ItemTitle>
        </ItemContent>
        <ItemActions>
          <ChevronRightIcon class="size-4" />
        </ItemActions>
      </a>
    </Item>
  </div>
</template>

You can add icons, avatars, or images to the item.

Security Alert

New login detected from unknown device.

<script setup lang="ts">
import { ShieldAlertIcon } from '@lucide/vue'
import { Button } from '@/components/ui/button'
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from '@/components/ui/item'
</script>

<template>
  <div class="flex w-full max-w-lg flex-col gap-6">
    <Item variant="outline">
      <ItemMedia variant="icon">
        <ShieldAlertIcon />
      </ItemMedia>
      <ItemContent>
        <ItemTitle>Security Alert</ItemTitle>
        <ItemDescription>
          New login detected from unknown device.
        </ItemDescription>
      </ItemContent>
      <ItemActions>
        <Button size="sm" variant="outline">
          Review
        </Button>
      </ItemActions>
    </Item>
  </div>
</template>
ER
Evil Rabbit

Last seen 5 months ago

ER
No Team Members

Invite your team to collaborate on this project.

<script setup lang="ts">
import { Plus } from '@lucide/vue'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from '@/components/ui/item'
</script>

<template>
  <div class="flex w-full max-w-lg flex-col gap-6">
    <Item variant="outline">
      <ItemMedia>
        <Avatar class="size-10">
          <AvatarImage src="https://github.com/evilrabbit.png" />
          <AvatarFallback>ER</AvatarFallback>
        </Avatar>
      </ItemMedia>
      <ItemContent>
        <ItemTitle>Evil Rabbit</ItemTitle>
        <ItemDescription>Last seen 5 months ago</ItemDescription>
      </ItemContent>
      <ItemActions>
        <Button
          size="icon-sm"
          variant="outline"
          class="rounded-full"
          aria-label="Invite"
        >
          <Plus />
        </Button>
      </ItemActions>
    </Item>
    <Item variant="outline">
      <ItemMedia>
        <div class="*:data-[slot=avatar]:ring-background flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:grayscale">
          <Avatar class="hidden sm:flex">
            <AvatarImage src="https://github.com/shadcn.png" alt="@shadcn" />
            <AvatarFallback>CN</AvatarFallback>
          </Avatar>
          <Avatar class="hidden sm:flex">
            <AvatarImage
              src="https://github.com/maxleiter.png"
              alt="@maxleiter"
            />
            <AvatarFallback>LR</AvatarFallback>
          </Avatar>
          <Avatar>
            <AvatarImage
              src="https://github.com/evilrabbit.png"
              alt="@evilrabbit"
            />
            <AvatarFallback>ER</AvatarFallback>
          </Avatar>
        </div>
      </ItemMedia>
      <ItemContent>
        <ItemTitle>No Team Members</ItemTitle>
        <ItemDescription>
          Invite your team to collaborate on this project.
        </ItemDescription>
      </ItemContent>
      <ItemActions>
        <Button size="sm" variant="outline">
          Invite
        </Button>
      </ItemActions>
    </Item>
  </div>
</template>

And here's what a list of items looks like with ItemGroup:

s
shadcn

shadcn@vercel.com

m
maxleiter

maxleiter@vercel.com

e
evilrabbit

evilrabbit@vercel.com

<script setup lang="ts">
import { Plus } from '@lucide/vue'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemMedia,
  ItemSeparator,
  ItemTitle,
} from '@/components/ui/item'

const people = [
  {
    username: 'shadcn',
    avatar: 'https://github.com/shadcn.png',
    email: 'shadcn@vercel.com',
  },
  {
    username: 'maxleiter',
    avatar: 'https://github.com/maxleiter.png',
    email: 'maxleiter@vercel.com',
  },
  {
    username: 'evilrabbit',
    avatar: 'https://github.com/evilrabbit.png',
    email: 'evilrabbit@vercel.com',
  },
]
</script>

<template>
  <div class="flex w-full max-w-md flex-col gap-6">
    <ItemGroup>
      <template v-for="(person, index) in people" :key="person.username">
        <Item>
          <ItemMedia>
            <Avatar>
              <AvatarImage :src="person.avatar" class="grayscale" />
              <AvatarFallback>{{ person.username.charAt(0) }}</AvatarFallback>
            </Avatar>
          </ItemMedia>
          <ItemContent class="gap-1">
            <ItemTitle>{{ person.username }}</ItemTitle>
            <ItemDescription>{{ person.email }}</ItemDescription>
          </ItemContent>
          <ItemActions>
            <Button variant="ghost" size="icon" class="rounded-full">
              <Plus />
            </Button>
          </ItemActions>
        </Item>
        <ItemSeparator v-if="index !== people.length - 1" />
      </template>
    </ItemGroup>
  </div>
</template>

Need it as a link? Use the asChild prop:

<template>
  <Item as-child>
    <a href="/dashboard">
      <ItemMedia variant="icon">
        <HomeIcon />
      </ItemMedia>
      <ItemContent>
        <ItemTitle>Dashboard</ItemTitle>
        <ItemDescription>Overview of your account and activity.</ItemDescription>
      </ItemContent>
    </a>
  </Item>
</template>
<script setup lang="ts">
import { ChevronRightIcon, ExternalLinkIcon } from '@lucide/vue'

import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemTitle,
} from '@/components/ui/item'
</script>

<template>
  <div class="flex w-full max-w-md flex-col gap-4">
    <Item as-child>
      <a href="#">
        <ItemContent>
          <ItemTitle>Visit our documentation</ItemTitle>
          <ItemDescription>
            Learn how to get started with our components.
          </ItemDescription>
        </ItemContent>
        <ItemActions>
          <ChevronRightIcon class="size-4" />
        </ItemActions>
      </a>
    </Item>
    <Item variant="outline" as-child>
      <a href="#" target="_blank" rel="noopener noreferrer">
        <ItemContent>
          <ItemTitle>External resource</ItemTitle>
          <ItemDescription>
            Opens in a new tab with security attributes.
          </ItemDescription>
        </ItemContent>
        <ItemActions>
          <ExternalLinkIcon class="size-4" />
        </ItemActions>
      </a>
    </Item>
  </div>
</template>

Empty

Okay last one: Empty. Use this to display empty states in your app.

Here's how you use it:

<script setup lang="ts">
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyMedia,
  EmptyTitle,
} from '@/components/ui/empty'
</script>

<template>
  <Empty>
    <EmptyMedia variant="icon">
      <InboxIcon />
    </EmptyMedia>
    <EmptyTitle>No messages</EmptyTitle>
    <EmptyDescription>You don't have any messages yet.</EmptyDescription>
    <EmptyContent>
      <Button>Send a message</Button>
    </EmptyContent>
  </Empty>
</template>
No Projects Yet

You haven't created any projects yet. Get started by creating your first project.

Learn More
<script setup lang="ts">
import { ArrowUpRightIcon, FolderCode } from '@lucide/vue'
import { Button } from '@/components/ui/button'
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from '@/components/ui/empty'
</script>

<template>
  <Empty>
    <EmptyHeader>
      <EmptyMedia variant="icon">
        <FolderCode />
      </EmptyMedia>
      <EmptyTitle>No Projects Yet</EmptyTitle>
      <EmptyDescription>
        You haven't created any projects yet. Get started by creating your first
        project.
      </EmptyDescription>
    </EmptyHeader>
    <EmptyContent>
      <div class="flex gap-2">
        <Button>Create Project</Button>
        <Button variant="outline">
          Import Project
        </Button>
      </div>
    </EmptyContent>
    <Button variant="link" as-child class="text-muted-foreground" size="sm">
      <a href="#">
        Learn More <ArrowUpRightIcon />
      </a>
    </Button>
  </Empty>
</template>

You can use it with avatars:

ZN
User Offline

This user is currently offline. You can leave a message to notify them or try again later.

<script setup lang="ts">
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from '@/components/ui/empty'
</script>

<template>
  <Empty>
    <EmptyHeader>
      <EmptyMedia variant="default">
        <Avatar class="size-12">
          <AvatarImage
            src="https://github.com/zernonia.png"
            class="grayscale"
          />
          <AvatarFallback>ZN</AvatarFallback>
        </Avatar>
      </EmptyMedia>
      <EmptyTitle>User Offline</EmptyTitle>
      <EmptyDescription>
        This user is currently offline. You can leave a message to notify them
        or try again later.
      </EmptyDescription>
    </EmptyHeader>
    <EmptyContent>
      <Button size="sm">
        Leave Message
      </Button>
    </EmptyContent>
  </Empty>
</template>

Or with input groups for things like search results or email subscriptions:

404 - Not Found

The page you're looking for doesn't exist. Try searching for what you need below.

/

Need help? Contact support

<script setup lang="ts">
import { SearchIcon } from '@lucide/vue'
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyTitle,
} from '@/components/ui/empty'
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from '@/components/ui/input-group'
import { Kbd } from '@/components/ui/kbd'
</script>

<template>
  <Empty>
    <EmptyHeader>
      <EmptyTitle>404 - Not Found</EmptyTitle>
      <EmptyDescription>
        The page you're looking for doesn't exist. Try searching for what you
        need below.
      </EmptyDescription>
    </EmptyHeader>
    <EmptyContent>
      <InputGroup class="sm:w-3/4">
        <InputGroupInput placeholder="Try searching for pages..." />
        <InputGroupAddon>
          <SearchIcon />
        </InputGroupAddon>
        <InputGroupAddon align="inline-end">
          <Kbd>/</Kbd>
        </InputGroupAddon>
      </InputGroup>
      <EmptyDescription>
        Need help? <a href="#">Contact support</a>
      </EmptyDescription>
    </EmptyContent>
  </Empty>
</template>

That's it. Seven new components. Works with all your libraries. Ready for your projects.


February 2025 - Reka UI & npx shadcn-vue@latest init

We've updated the latest registry to support Reka UI instead of Radix Vue.

The updated CLI is now available. You can now install components, themes, composables, utils and more using npx shadcn-vue add.

This is a major step towards distributing code that you and your LLMs can access and use.

  1. First up, when you init into a new app, we update your existing Tailwind files instead of overriding.
  2. A component now ship its own dependencies. Take the accordion for example, it can define its Tailwind keyframes. When you add it to your project, we’ll update your tailwind.config.ts file accordingly.
  3. You can also install remote components using url. npx shadcn-vue add https://acme.com/registry/navbar.json.
  1. We have created a new schema that you can use to ship your own component registry. And since it has support for urls, you can even use it to distribute private components.
  2. And a few more updates like better error handling and monorepo support.

You can try the new cli today.

pnpm dlx shadcn-vue@latest init Sidebar01 Login01