Skip to content
edmi-ui

Sources

Collapsible list of the sources a response used.

StackBlitz ↗StackBlitz ↗StackBlitz ↗

Built on collapsible.

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

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-sources
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/ai-sources
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/ai-sources
Terminal window
bunx shadcn-vue@latest add @edmi-ui/ai-sources

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-sources.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-sources.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-sources.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-sources.json

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

import {
Sources,
SourcesContent,
SourcesTrigger,
} from "@/components/ai/sources";
<div className="flex w-full max-w-lg flex-col gap-6">
<Sources>
<SourcesTrigger />
<SourcesContent>
</SourcesContent>
</Sources>
<Sources defaultOpen>
<SourcesTrigger />
<SourcesContent>
</SourcesContent>
</Sources>
</div>
<script setup lang="ts">
import {
Source,
Sources,
SourcesContent,
SourcesTrigger,
} from "@/components/ai/sources";
</script>
<template>
<div class="flex w-full max-w-lg flex-col gap-6">
<Sources>
<SourcesTrigger />
<SourcesContent>
<Source :key="s.href" :href="s.href" :title="s.title" />
</SourcesContent>
</Sources>
<Sources default-open>
<SourcesTrigger />
<SourcesContent>
<Source :key="s.href" :href="s.href" :title="s.title" />
</SourcesContent>
</Sources>
</div>
</template>
<script lang="ts">
import {
Source,
Sources,
SourcesContent,
SourcesTrigger,
} from "$lib/components/ai/sources";
</script>
<div class="flex w-full max-w-lg flex-col gap-6">
<Sources>
<SourcesTrigger />
<SourcesContent>
{#each sources as s (s.href)}
<Source href={s.href} title={s.title} />
{/each}
</SourcesContent>
</Sources>
<Sources open>
<SourcesTrigger />
<SourcesContent>
{#each sources as s (s.href)}
<Source href={s.href} title={s.title} />
{/each}
</SourcesContent>
</Sources>
</div>
registry/components/ai/sources.tsx
"use client";
import { cn } from "cn";
import type { ComponentProps } from "react";
import { BookmarkIcon, CaretDownIcon, GlobeIcon } from "@phosphor-icons/react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/registry/edmi/ui/collapsible";
export type SourcesProps = ComponentProps<typeof Collapsible>;
export const Sources = ({ className, ...props }: SourcesProps) => (
<Collapsible
data-slot="ai-sources"
className={cn("not-prose text-[13.5px]", className)}
{...props}
/>
);
export type SourcesTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
count: number;
};
export const SourcesTrigger = ({
className,
count,
children,
...props
}: SourcesTriggerProps) => (
<CollapsibleTrigger
data-slot="ai-sources-trigger"
className={cn(
"group/sources-trigger flex w-full items-center gap-2 text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
className,
)}
{...props}
>
{children ?? (
<>
<BookmarkIcon
className="size-4"
/>
<span>
Used {count} {count === 1 ? "source" : "sources"}
</span>
<CaretDownIcon
className="ml-auto size-4 transition-transform group-data-[panel-open]/sources-trigger:rotate-180"
/>
</>
)}
</CollapsibleTrigger>
);
export type SourcesContentProps = ComponentProps<typeof CollapsibleContent>;
export const SourcesContent = ({
className,
children,
...props
}: SourcesContentProps) => (
<CollapsibleContent
data-slot="ai-sources-content"
className={cn(
"h-(--collapsible-panel-height) overflow-hidden outline-none transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0",
className,
)}
{...props}
>
<div className="flex flex-col gap-1.5 pt-2 pl-[23px]">{children}</div>
</CollapsibleContent>
);
export type SourceProps = ComponentProps<"a">;
const hostOf = (href?: string) => {
if (!href) return undefined;
try {
return new URL(href).hostname.replace(/^www\./, "");
} catch {
return undefined;
}
};
export const Source = ({
href,
title,
className,
children,
...props
}: SourceProps) => {
const host = hostOf(href);
return (
<a
data-slot="ai-source"
className={cn("flex items-center gap-2 text-foreground", className)}
href={href}
rel="noreferrer"
target="_blank"
{...props}
>
{children ?? (
<>
<GlobeIcon
className="size-3.5 shrink-0 text-muted-foreground"
/>
<span className="underline underline-offset-[3px]">{title}</span>
{host && (
<span className="font-mono text-[11.5px] text-muted-foreground">
{host}
</span>
)}
</>
)}
</a>
);
};
registry/components/ai/sources/Source.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { GlobeIcon } from "@lucide/vue"
import { computed } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{
href?: string
title?: string
class?: HTMLAttributes["class"]
}>()
const host = computed(() => {
if (!props.href)
return undefined
try {
return new URL(props.href).hostname.replace(/^www\./, "")
}
catch {
return undefined
}
})
</script>
<template>
<a
data-slot="ai-source"
:class="cn('flex items-center gap-2 text-foreground', props.class)"
:href="href"
rel="noreferrer"
target="_blank"
>
<slot>
<GlobeIcon class="size-3.5 shrink-0 text-muted-foreground" />
<span class="underline underline-offset-[3px]">{{ title }}</span>
<span v-if="host" class="font-mono text-[11.5px] text-muted-foreground">{{ host }}</span>
</slot>
</a>
</template>
registry/components/ai/sources/Sources.vue
<script setup lang="ts">
import type { CollapsibleRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { Collapsible } from "@/registry/edmi/ui/collapsible"
interface SourcesProps extends /* @vue-ignore */ CollapsibleRootProps {
class?: HTMLAttributes["class"]
}
const props = defineProps<SourcesProps>()
</script>
<template>
<!-- Collapsible props (open, defaultOpen, update:open) fall through as attrs. -->
<Collapsible data-slot="ai-sources" :class="cn('not-prose text-[13.5px]', props.class)">
<slot />
</Collapsible>
</template>
registry/components/ai/sources/SourcesContent.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { CollapsibleContent } from "@/registry/edmi/ui/collapsible"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<CollapsibleContent
data-slot="ai-sources-content"
:class="cn(
'overflow-hidden outline-none data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down',
props.class,
)"
>
<div class="flex flex-col gap-1.5 pt-2 pl-[23px]">
<slot />
</div>
</CollapsibleContent>
</template>
registry/components/ai/sources/SourcesTrigger.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { BookmarkIcon, ChevronDownIcon } from "@lucide/vue"
import { cn } from "@/registry/edmi/lib/utils"
import { CollapsibleTrigger } from "@/registry/edmi/ui/collapsible"
const props = defineProps<{
count: number
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<CollapsibleTrigger
data-slot="ai-sources-trigger"
:class="cn(
'group/sources-trigger flex w-full items-center gap-2 text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
props.class,
)"
>
<slot>
<BookmarkIcon class="size-4" />
<span>Used {{ count }} {{ count === 1 ? "source" : "sources" }}</span>
<ChevronDownIcon
class="ml-auto size-4 transition-transform group-data-[state=open]/sources-trigger:rotate-180"
/>
</slot>
</CollapsibleTrigger>
</template>
registry/components/ai/sources/index.ts
export { default as Source } from "./Source.vue";
export { default as Sources } from "./Sources.vue";
export { default as SourcesContent } from "./SourcesContent.vue";
export { default as SourcesTrigger } from "./SourcesTrigger.vue";
src/lib/registry/ai/sources/index.ts
import Source from "./source.svelte";
import Root from "./sources.svelte";
import Content from "./sources-content.svelte";
import Trigger from "./sources-trigger.svelte";
export {
Content,
Content as SourcesContent,
Root,
//
Root as Sources,
Source,
Trigger,
Trigger as SourcesTrigger,
};
src/lib/registry/ai/sources/source.svelte
<script lang="ts">
import GlobeIcon from "phosphor-svelte/lib/Globe";
import { cn } from "$lib/utils.js";
import type { HTMLAnchorAttributes } from "svelte/elements";
let { class: className, href, title, children, ...restProps }: HTMLAnchorAttributes = $props();
const host = $derived.by(() => {
if (!href) return undefined;
try {
return new URL(href).hostname.replace(/^www\./, "");
} catch {
return undefined;
}
});
</script>
<a
data-slot="ai-source"
class={cn("flex items-center gap-2 text-foreground", className)}
{href}
rel="noreferrer"
target="_blank"
{...restProps}
>
{#if children}
{@render children()}
{:else}
<GlobeIcon
class="size-3.5 shrink-0 text-muted-foreground"
/>
<span class="underline underline-offset-[3px]">{title}</span>
{#if host}
<span class="font-mono text-[11.5px] text-muted-foreground">{host}</span>
{/if}
{/if}
</a>
src/lib/registry/ai/sources/sources-content.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import { CollapsibleContent } from "$lib/registry/ui/collapsible/index.js";
import type { ComponentProps } from "svelte";
let {
class: className,
children,
...restProps
}: ComponentProps<typeof CollapsibleContent> = $props();
</script>
<CollapsibleContent
data-slot="ai-sources-content"
class={cn(
"overflow-hidden outline-none data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down",
className
)}
{...restProps}
>
<div class="flex flex-col gap-1.5 pt-2 pl-[23px]">
{@render children?.()}
</div>
</CollapsibleContent>
src/lib/registry/ai/sources/sources-trigger.svelte
<script lang="ts">
import BookmarkIcon from "phosphor-svelte/lib/Bookmark";
import CaretDownIcon from "phosphor-svelte/lib/CaretDown";
import { cn } from "$lib/utils.js";
import { CollapsibleTrigger } from "$lib/registry/ui/collapsible/index.js";
import type { ComponentProps } from "svelte";
let {
class: className,
count,
children,
...restProps
}: ComponentProps<typeof CollapsibleTrigger> & { count: number } = $props();
</script>
<CollapsibleTrigger
data-slot="ai-sources-trigger"
class={cn(
"group/sources-trigger flex w-full items-center gap-2 text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
className
)}
{...restProps}
>
{#if children}
{@render children()}
{:else}
<BookmarkIcon
class="size-4"
/>
<span>Used {count} {count === 1 ? "source" : "sources"}</span>
<CaretDownIcon
class="ml-auto size-4 transition-transform group-data-[state=open]/sources-trigger:rotate-180"
/>
{/if}
</CollapsibleTrigger>
src/lib/registry/ai/sources/sources.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Collapsible } from "$lib/registry/ui/collapsible/index.js";
import type { ComponentProps } from "svelte";
let {
class: className,
open = $bindable(false),
children,
...restProps
}: ComponentProps<typeof Collapsible> = $props();
</script>
<Collapsible bind:open data-slot="ai-sources" class={cn("not-prose text-[13.5px]", className)} {...restProps}>
{@render children?.()}
</Collapsible>
Part Prop Type Default
Sources open, defaultOpen, onOpenChange collapsible state closed
SourcesTrigger count number
Source href, title link props
  • Source shows the host (reuters.com) in mono next to the underlined title, derived from href.
  • The trigger spans the row with the chevron on the right, like the other agent-output disclosures.