Skip to content
edmi-ui

Chat Header

Conversation header: title, agent or model, share and more actions.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Keeper starter plan
Keeper starter plan
Keeper starter plan

Edmi ✦ addition. Built on button and dropdown-menu.

Terminal window
npx shadcn@latest add @edmi-ui/ai-chat-header
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/ai-chat-header
Terminal window
yarn dlx shadcn@latest add @edmi-ui/ai-chat-header
Terminal window
bunx shadcn@latest add @edmi-ui/ai-chat-header

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/ai-chat-header
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/ai-chat-header
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/ai-chat-header
Terminal window
bunx shadcn-vue@latest add @edmi-ui/ai-chat-header

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-chat-header.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-chat-header.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-chat-header.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-chat-header.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

import {
ChatHeader,
ChatHeaderMenu,
ChatHeaderProject,
ChatHeaderTitle,
} from "@/components/ai/chat-header";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
<ChatHeader className="max-w-3xl">
<ChatHeaderTitle>
<span className="truncate">Keeper starter plan</span>
<ChatHeaderProject status="connected" />
<ChatHeaderMenu>
<DropdownMenuItem>Rename</DropdownMenuItem>
<DropdownMenuItem>Move to project</DropdownMenuItem>
<DropdownMenuItem variant="destructive">Delete</DropdownMenuItem>
</ChatHeaderMenu>
</ChatHeaderTitle>
{/* … */}
</ChatHeader>
<script setup lang="ts">
import {
ChatHeader,
ChatHeaderActions,
ChatHeaderMenu,
ChatHeaderProject,
ChatHeaderShare,
ChatHeaderTitle,
} from "@/components/ai/chat-header";
import { Button } from "@/components/ui/button";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
</script>
<template>
<ChatHeader class="max-w-3xl">
<ChatHeaderTitle>
<span class="truncate">Keeper starter plan</span>
<ChatHeaderProject status="connected" />
<ChatHeaderMenu>
<DropdownMenuItem>Rename</DropdownMenuItem>
<DropdownMenuItem>Move to project</DropdownMenuItem>
<DropdownMenuItem variant="destructive">Delete</DropdownMenuItem>
</ChatHeaderMenu>
</ChatHeaderTitle>
<ChatHeaderActions>
<Button aria-label="Web access" size="icon-sm" type="button" variant="ghost">
<GlobeIcon class="size-4" />
</Button>
<Button aria-label="1 attached file" size="sm" type="button" variant="ghost">
<FileTextIcon class="size-3.5" />
1
</Button>
<ChatHeaderShare />
</ChatHeaderActions>
</ChatHeader>
</template>
<script lang="ts">
import {
ChatHeader,
ChatHeaderActions,
ChatHeaderMenu,
ChatHeaderProject,
ChatHeaderShare,
ChatHeaderTitle,
} from "$lib/components/ai/chat-header";
import { Button } from "$lib/components/ui/button";
import { DropdownMenuItem } from "$lib/components/ui/dropdown-menu";
</script>
<ChatHeader class="max-w-3xl">
<ChatHeaderTitle>
<span class="truncate">Keeper starter plan</span>
<ChatHeaderProject status="connected" />
<ChatHeaderMenu>
<DropdownMenuItem>Rename</DropdownMenuItem>
<DropdownMenuItem>Move to project</DropdownMenuItem>
<DropdownMenuItem variant="destructive">Delete</DropdownMenuItem>
</ChatHeaderMenu>
</ChatHeaderTitle>
<ChatHeaderActions>
<Button aria-label="Web access" size="icon-sm" type="button" variant="ghost">
<GlobeIcon class="size-4" />
</Button>
<Button aria-label="1 attached file" size="sm" type="button" variant="ghost">
<FileTextIcon class="size-3.5" />
1
</Button>
<ChatHeaderShare />
</ChatHeaderActions>
</ChatHeader>
registry/components/ai/chat-header.tsx
import { cn } from "cn";
import type { ComponentProps, ReactNode } from "react";
import { HardDrivesIcon, CaretDownIcon } from "@phosphor-icons/react";
import { Button } from "@/registry/edmi/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/registry/edmi/ui/dropdown-menu";
export type ChatHeaderProps = ComponentProps<"header">;
/** Thread bar: title, project indicator and actions. */
export const ChatHeader = ({ className, ...props }: ChatHeaderProps) => (
<header
data-slot="ai-chat-header"
className={cn(
"flex h-[52px] w-full items-center justify-between gap-3 rounded-[calc(var(--radius)*1.2)] border border-border bg-background pr-2.5 pl-4",
className,
)}
{...props}
/>
);
export type ChatHeaderTitleProps = ComponentProps<"div">;
/** Title with its indicators; put `ChatHeaderProject` and `ChatHeaderMenu` inside. */
export const ChatHeaderTitle = ({
className,
...props
}: ChatHeaderTitleProps) => (
<div
data-slot="ai-chat-header-title"
className={cn("flex min-w-0 items-center gap-2.5 text-[14.5px]", className)}
{...props}
/>
);
export type ChatHeaderProjectProps = ComponentProps<"span"> & {
/** `connected` shows the green dot. */
status?: "connected" | "idle";
icon?: ReactNode;
};
/** Project icon with a status dot (dot = connected). */
export const ChatHeaderProject = ({
status = "connected",
icon,
className,
...props
}: ChatHeaderProjectProps) => (
<span
data-slot="ai-chat-header-project"
data-status={status}
className={cn("relative inline-flex text-muted-foreground", className)}
{...props}
>
{icon ?? (
<HardDrivesIcon
className="size-4"
/>
)}
{status === "connected" && (
<span className="absolute -top-px -right-0.5 size-1.5 rounded-full bg-success" />
)}
</span>
);
export type ChatHeaderMenuProps = ComponentProps<typeof DropdownMenuContent>;
/** Chevron after the title; children are the `DropdownMenuItem`s. */
export const ChatHeaderMenu = ({ children, ...props }: ChatHeaderMenuProps) => (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
aria-label="Conversation menu"
size="icon-xs"
type="button"
variant="ghost"
/>
}
>
<CaretDownIcon
className="size-4"
/>
</DropdownMenuTrigger>
<DropdownMenuContent {...props}>{children}</DropdownMenuContent>
</DropdownMenu>
);
export type ChatHeaderActionsProps = ComponentProps<"div">;
export const ChatHeaderActions = ({
className,
...props
}: ChatHeaderActionsProps) => (
<div
data-slot="ai-chat-header-actions"
className={cn("flex shrink-0 items-center gap-1", className)}
{...props}
/>
);
export type ChatHeaderShareProps = ComponentProps<typeof Button>;
export const ChatHeaderShare = ({
children = "Share",
...props
}: ChatHeaderShareProps) => (
<Button size="sm" type="button" variant="secondary" {...props}>
{children}
</Button>
);
registry/components/ai/chat-header/ChatHeader.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<!-- Thread bar: title, project indicator and actions. -->
<header
data-slot="ai-chat-header"
:class="cn('flex h-[52px] w-full items-center justify-between gap-3 rounded-[calc(var(--radius)*1.2)] border border-border bg-background pr-2.5 pl-4', props.class)"
>
<slot />
</header>
</template>
registry/components/ai/chat-header/ChatHeaderActions.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div data-slot="ai-chat-header-actions" :class="cn('flex shrink-0 items-center gap-1', props.class)">
<slot />
</div>
</template>
registry/components/ai/chat-header/ChatHeaderMenu.vue
<script setup lang="ts">
import { ChevronDown } from "@lucide/vue"
import { Button } from "@/registry/edmi/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/registry/edmi/ui/dropdown-menu"
</script>
<template>
<!-- Chevron after the title; the default slot holds the `DropdownMenuItem`s. -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button aria-label="Conversation menu" size="icon-xs" type="button" variant="ghost">
<ChevronDown class="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<slot />
</DropdownMenuContent>
</DropdownMenu>
</template>
registry/components/ai/chat-header/ChatHeaderProject.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { ServerIcon } from "@lucide/vue"
import { cn } from "@/registry/edmi/lib/utils"
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
/** `connected` shows the green dot. */
status?: "connected" | "idle"
}>(), {
status: "connected",
})
</script>
<template>
<!-- Project icon with a status dot (dot = connected). The icon slot replaces the default. -->
<span
data-slot="ai-chat-header-project"
:data-status="status"
:class="cn('relative inline-flex text-muted-foreground', props.class)"
>
<slot>
<ServerIcon class="size-4" />
</slot>
<span v-if="status === 'connected'" class="absolute -top-px -right-0.5 size-1.5 rounded-full bg-success" />
</span>
</template>
registry/components/ai/chat-header/ChatHeaderShare.vue
<script setup lang="ts">
import { Button } from "@/registry/edmi/ui/button"
</script>
<template>
<Button size="sm" type="button" variant="secondary">
<slot>Share</slot>
</Button>
</template>
registry/components/ai/chat-header/ChatHeaderTitle.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<!-- Title with its indicators; put `ChatHeaderProject` and `ChatHeaderMenu` inside. -->
<div data-slot="ai-chat-header-title" :class="cn('flex min-w-0 items-center gap-2.5 text-[14.5px]', props.class)">
<slot />
</div>
</template>
registry/components/ai/chat-header/index.ts
export { default as ChatHeader } from "./ChatHeader.vue";
export { default as ChatHeaderActions } from "./ChatHeaderActions.vue";
export { default as ChatHeaderMenu } from "./ChatHeaderMenu.vue";
export { default as ChatHeaderProject } from "./ChatHeaderProject.vue";
export { default as ChatHeaderShare } from "./ChatHeaderShare.vue";
export { default as ChatHeaderTitle } from "./ChatHeaderTitle.vue";
src/lib/registry/ai/chat-header/chat-header-actions.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let { ref = $bindable(null), class: className, children, ...restProps }: WithElementRef<HTMLAttributes<HTMLDivElement>, HTMLDivElement> = $props();
</script>
<div
bind:this={ref}
data-slot="ai-chat-header-actions"
class={cn("flex shrink-0 items-center gap-1", className)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ai/chat-header/chat-header-menu.svelte
<script lang="ts">
import CaretDownIcon from "phosphor-svelte/lib/CaretDown";
import { Button } from "$lib/registry/ui/button/index.js";
import * as DropdownMenu from "$lib/registry/ui/dropdown-menu/index.js";
import type { ComponentProps } from "svelte";
let { children, ...restProps }: ComponentProps<typeof DropdownMenu.Content> = $props();
</script>
<!-- Chevron after the title; children are the `DropdownMenu.Item`s. -->
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} aria-label="Conversation menu" size="icon-xs" type="button" variant="ghost">
<CaretDownIcon
class="size-4"
/>
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content {...restProps}>
{@render children?.()}
</DropdownMenu.Content>
</DropdownMenu.Root>
src/lib/registry/ai/chat-header/chat-header-project.svelte
<script lang="ts">
import HardDrivesIcon from "phosphor-svelte/lib/HardDrives";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
status = "connected",
class: className,
children,
...restProps
}: WithElementRef<Omit<HTMLAttributes<HTMLSpanElement>, "children">, HTMLSpanElement> & {
/** `connected` shows the green dot. */
status?: "connected" | "idle";
/** Replaces the default project icon. */
children?: Snippet;
} = $props();
</script>
<!-- Project icon with a status dot (dot = connected). -->
<span
bind:this={ref}
data-slot="ai-chat-header-project"
data-status={status}
class={cn("relative inline-flex text-muted-foreground", className)}
{...restProps}
>
{#if children}
{@render children()}
{:else}
<HardDrivesIcon
class="size-4"
/>
{/if}
{#if status === "connected"}
<span class="absolute -top-px -right-0.5 size-1.5 rounded-full bg-success"></span>
{/if}
</span>
src/lib/registry/ai/chat-header/chat-header-share.svelte
<script lang="ts">
import { Button, type ButtonProps } from "$lib/registry/ui/button/index.js";
let { ref = $bindable(null), children, ...restProps }: Omit<ButtonProps, "href"> = $props();
</script>
<Button bind:ref size="sm" type="button" variant="secondary" {...restProps}>
{#if children}
{@render children()}
{:else}
Share
{/if}
</Button>
src/lib/registry/ai/chat-header/chat-header-title.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let { ref = $bindable(null), class: className, children, ...restProps }: WithElementRef<HTMLAttributes<HTMLDivElement>, HTMLDivElement> = $props();
</script>
<!-- Title with its indicators; put `ChatHeaderProject` and `ChatHeaderMenu` inside. -->
<div
bind:this={ref}
data-slot="ai-chat-header-title"
class={cn("flex min-w-0 items-center gap-2.5 text-[14.5px]", className)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ai/chat-header/chat-header.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let { ref = $bindable(null), class: className, children, ...restProps }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<!-- Thread bar: title, project indicator and actions. -->
<header
bind:this={ref}
data-slot="ai-chat-header"
class={cn(
"flex h-[52px] w-full items-center justify-between gap-3 rounded-[calc(var(--radius)*1.2)] border border-border bg-background pr-2.5 pl-4",
className
)}
{...restProps}
>
{@render children?.()}
</header>
src/lib/registry/ai/chat-header/index.ts
import ChatHeader from "./chat-header.svelte";
import ChatHeaderActions from "./chat-header-actions.svelte";
import ChatHeaderMenu from "./chat-header-menu.svelte";
import ChatHeaderProject from "./chat-header-project.svelte";
import ChatHeaderShare from "./chat-header-share.svelte";
import ChatHeaderTitle from "./chat-header-title.svelte";
export {
ChatHeader,
ChatHeaderActions,
ChatHeaderMenu,
ChatHeaderProject,
ChatHeaderShare,
ChatHeaderTitle,
};
Part Prop Type Default
ChatHeader header props
ChatHeaderTitle title row
ChatHeaderProject status, icon connected | idle, ReactNode (Vue: default slot, Svelte: children snippet) connected
ChatHeaderMenu DropdownMenuContent props chevron with the DropdownMenuItems as children
ChatHeaderActions right side
ChatHeaderShare Button props Share

Flat only. The header is a bar with a border; no elevation prop.

  • Title with a project indicator (dot = connected), a menu chevron, and actions on the right.