Skip to content
edmi-ui

Artifact Card

File card for generated output: paper thumbnail, format meta, split Download and a generating state.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Keeper starter
ZIP
Rebalance report
PDF · writing…
Keeper starter
ZIP
Rebalance report
PDF · writing…
Keeper starter
ZIP
Rebalance report
PDF · writing…

Edmi ✦ addition. Built on card, button, button-group, dropdown-menu and ai-shimmer.

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

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

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

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

import {
ArtifactCard,
ArtifactCardActions,
ArtifactCardBody,
ArtifactCardIcon,
ArtifactCardMeta,
ArtifactCardTitle,
} from "@/components/ai/artifact-card";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
<div className="flex w-full max-w-lg flex-col gap-3">
<ArtifactCard>
<ArtifactCardIcon kind="archive" />
<ArtifactCardBody>
<ArtifactCardTitle>Keeper starter</ArtifactCardTitle>
<ArtifactCardMeta>ZIP</ArtifactCardMeta>
</ArtifactCardBody>
<ArtifactCardActions>
<DropdownMenuItem>Copy link</DropdownMenuItem>
<DropdownMenuItem>Open</DropdownMenuItem>
</ArtifactCardActions>
</ArtifactCard>
<ArtifactCard state="generating">
<ArtifactCardIcon kind="document" />
<ArtifactCardBody>
<ArtifactCardTitle>Rebalance report</ArtifactCardTitle>
<ArtifactCardMeta>PDF · writing…</ArtifactCardMeta>
</ArtifactCardBody>
<ArtifactCardActions />
</ArtifactCard>
</div>
<script setup lang="ts">
import {
ArtifactCard,
ArtifactCardActions,
ArtifactCardBody,
ArtifactCardIcon,
ArtifactCardMeta,
ArtifactCardTitle,
} from "@/components/ai/artifact-card";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
</script>
<template>
<div class="flex w-full max-w-lg flex-col gap-3">
<ArtifactCard>
<ArtifactCardIcon kind="archive" />
<ArtifactCardBody>
<ArtifactCardTitle>Keeper starter</ArtifactCardTitle>
<ArtifactCardMeta>ZIP</ArtifactCardMeta>
</ArtifactCardBody>
<ArtifactCardActions>
<DropdownMenuItem>Copy link</DropdownMenuItem>
<DropdownMenuItem>Open</DropdownMenuItem>
</ArtifactCardActions>
</ArtifactCard>
<ArtifactCard state="generating">
<ArtifactCardIcon kind="document" />
<ArtifactCardBody>
<ArtifactCardTitle>Rebalance report</ArtifactCardTitle>
<ArtifactCardMeta>PDF · writing…</ArtifactCardMeta>
</ArtifactCardBody>
<ArtifactCardActions />
</ArtifactCard>
</div>
</template>
<script lang="ts">
import {
ArtifactCard,
ArtifactCardActions,
ArtifactCardBody,
ArtifactCardIcon,
ArtifactCardMeta,
ArtifactCardTitle,
} from "$lib/components/ai/artifact-card";
import { DropdownMenuItem } from "$lib/components/ui/dropdown-menu";
</script>
<div class="flex w-full max-w-lg flex-col gap-3">
<ArtifactCard>
<ArtifactCardIcon kind="archive" />
<ArtifactCardBody>
<ArtifactCardTitle>Keeper starter</ArtifactCardTitle>
<ArtifactCardMeta>ZIP</ArtifactCardMeta>
</ArtifactCardBody>
<ArtifactCardActions>
<DropdownMenuItem>Copy link</DropdownMenuItem>
<DropdownMenuItem>Open</DropdownMenuItem>
</ArtifactCardActions>
</ArtifactCard>
<ArtifactCard state="generating">
<ArtifactCardIcon kind="document" />
<ArtifactCardBody>
<ArtifactCardTitle>Rebalance report</ArtifactCardTitle>
<ArtifactCardMeta>PDF · writing…</ArtifactCardMeta>
</ArtifactCardBody>
<ArtifactCardActions />
</ArtifactCard>
</div>
registry/components/ai/artifact-card.tsx
"use client";
import { cn } from "cn";
import type { ComponentProps, ReactNode } from "react";
import { createContext, useContext } from "react";
import { ArchiveIcon, FileCodeIcon, ImageIcon, AppWindowIcon, FileIcon, FileTextIcon, CaretDownIcon } from "@phosphor-icons/react";
import { Shimmer } from "@/registry/edmi/components/ai/shimmer";
import { Button } from "@/registry/edmi/ui/button";
import { ButtonGroup } from "@/registry/edmi/ui/button-group";
import { Card } from "@/registry/edmi/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/registry/edmi/ui/dropdown-menu";
import type { Elevation } from "@/registry/edmi/ui/elevation";
export type ArtifactKind =
| "archive"
| "document"
| "code"
| "image"
| "slides"
| "file";
type ArtifactCardContextValue = { state: "ready" | "generating" };
const ArtifactCardContext = createContext<ArtifactCardContextValue>({
state: "ready",
});
export type ArtifactCardProps = Omit<
ComponentProps<typeof Card>,
"elevation"
> & {
/** ✦ depth: sunken -1, flat 0, raised +1, floating +2. */
elevation?: Elevation;
/** `generating`: the title shimmers and the actions are hidden. */
state?: "ready" | "generating";
};
export const ArtifactCard = ({
className,
state = "ready",
elevation,
children,
...props
}: ArtifactCardProps) => (
<ArtifactCardContext.Provider value={{ state }}>
<Card
data-slot="ai-artifact-card"
data-state={state}
elevation={elevation}
className={cn(
"w-full flex-row items-center gap-3.5 px-3.5 py-3 text-card-foreground",
className,
)}
{...props}
>
{children}
</Card>
</ArtifactCardContext.Provider>
);
/** Icon for a kind of file; shared with the Session Panel file rows. */
export const ArtifactKindIcon = ({
kind = "document",
className = "size-5",
}: {
kind?: ArtifactKind;
className?: string;
}) => {
switch (kind) {
case "archive":
return (
<ArchiveIcon
className={className}
/>
);
case "code":
return (
<FileCodeIcon
className={className}
/>
);
case "image":
return (
<ImageIcon
className={className}
/>
);
case "slides":
return (
<AppWindowIcon
className={className}
/>
);
case "file":
return (
<FileIcon
className={className}
/>
);
default:
return (
<FileTextIcon
className={className}
/>
);
}
};
export type ArtifactCardIconProps = ComponentProps<"span"> & {
kind?: ArtifactKind;
};
/** Icon tile for files without a thumbnail. */
export const ArtifactCardIcon = ({
kind = "document",
className,
children,
...props
}: ArtifactCardIconProps) => (
<span
data-slot="ai-artifact-card-icon"
className={cn(
"inline-flex h-14 w-[52px] shrink-0 items-center justify-center rounded-[calc(var(--radius)*0.9)] border border-border bg-muted text-muted-foreground",
className,
)}
{...props}
>
{children ?? <ArtifactKindIcon kind={kind} />}
</span>
);
// Paper is always light (DESIGN 5b rule 5): literal page colors, not themed tokens.
const PAPER_LINES = [92, 85, 78, 71, 64, 87, 80, 73];
export type ArtifactCardThumbnailProps = ComponentProps<"span"> & {
/** `paper`: cropped top of page 1 (always light). `slide`: dark slide. */
variant?: "paper" | "slide";
};
/** Cropped thumbnail (top of the first page): an inset tile the size of the icon tile, clipped to its radius. */
export const ArtifactCardThumbnail = ({
variant = "paper",
className,
children,
...props
}: ArtifactCardThumbnailProps) => (
<span
data-slot="ai-artifact-card-thumbnail"
data-variant={variant}
className={cn(
"block h-14 w-[52px] shrink-0 overflow-hidden rounded-[calc(var(--radius)*0.9)] border border-border",
className,
)}
{...props}
>
<span
className={cn(
"box-border block size-full overflow-hidden px-[6px] py-[7px]",
variant === "paper" ? "bg-white" : "bg-[#14213d]",
)}
>
{children ??
(variant === "paper" ? (
<>
<span className="block h-0.5 w-[40%] rounded-[2px] bg-[#d9d8d2]" />
<span className="mt-[3px] block h-1 w-[88%] rounded-[2px] bg-[#1f1f1d]" />
<span className="mt-[3px] block h-1 w-[63%] rounded-[2px] bg-[#1f1f1d]" />
{PAPER_LINES.map((w) => (
<span
key={w}
className="mt-[3px] block h-0.5 rounded-[2px] bg-[#d9d8d2]"
style={{ width: `${w}%` }}
/>
))}
</>
) : (
<>
<span className="block h-0.5 w-[40%] rounded-[2px] bg-[#e39a3c]" />
<span className="mt-3 block h-1 w-[80%] rounded-[2px] bg-white" />
<span className="mt-[3px] block h-1 w-[56%] rounded-[2px] bg-white" />
</>
))}
</span>
</span>
);
export type ArtifactCardBodyProps = ComponentProps<"div">;
export const ArtifactCardBody = ({
className,
...props
}: ArtifactCardBodyProps) => (
<div
data-slot="ai-artifact-card-body"
className={cn("min-w-0 flex-1", className)}
{...props}
/>
);
export type ArtifactCardTitleProps = ComponentProps<"div">;
export const ArtifactCardTitle = ({
className,
children,
...props
}: ArtifactCardTitleProps) => {
const { state } = useContext(ArtifactCardContext);
return (
<div
data-slot="ai-artifact-card-title"
className={cn("truncate text-[14.5px] font-medium", className)}
{...props}
>
{state === "generating" && typeof children === "string" ? (
<Shimmer as="span">{children}</Shimmer>
) : (
children
)}
</div>
);
};
export type ArtifactCardMetaProps = ComponentProps<"div">;
/** Meta line: kind and format (`Document · PDF`), or a status while generating. */
export const ArtifactCardMeta = ({
className,
...props
}: ArtifactCardMetaProps) => (
<div
data-slot="ai-artifact-card-meta"
className={cn(
"mt-[3px] font-mono text-[12.5px] text-muted-foreground",
className,
)}
{...props}
/>
);
export type ArtifactCardActionsProps = ComponentProps<typeof ButtonGroup> & {
/** Label of the primary half of the split button. */
label?: ReactNode;
onDownload?: () => void;
/** `DropdownMenuItem`s for the chevron half (copy link, open, ...). No chevron without them. */
children?: ReactNode;
};
/** Split Download button; hidden while the card is generating. */
export const ArtifactCardActions = ({
label = "Download",
onDownload,
children,
className,
...props
}: ArtifactCardActionsProps) => {
const { state } = useContext(ArtifactCardContext);
if (state === "generating") return null;
return (
<ButtonGroup
data-slot="ai-artifact-card-actions"
className={cn("shrink-0", className)}
{...props}
>
<Button variant="secondary" size="sm" type="button" onClick={onDownload}>
{label}
</Button>
{children && (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="secondary"
size="icon-sm"
type="button"
aria-label="More download options"
/>
}
>
<CaretDownIcon
className="size-3.5"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">{children}</DropdownMenuContent>
</DropdownMenu>
)}
</ButtonGroup>
);
};
registry/components/ai/artifact-card/ArtifactCard.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { ArtifactCardState } from "./context"
import { computed, provide } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { Card } from "@/registry/edmi/ui/card"
import type { Elevation } from "@/registry/edmi/ui/elevation"
import { ARTIFACT_CARD_KEY } from "./context"
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
/** `generating`: the title shimmers and the actions are hidden. */
state?: ArtifactCardState
/** ✦ depth: sunken -1, flat 0, raised +1, floating +2. */
elevation?: Elevation
}>(), {
state: "ready",
elevation: undefined,
})
provide(ARTIFACT_CARD_KEY, computed(() => props.state))
</script>
<template>
<Card
data-slot="ai-artifact-card"
:data-state="state"
:elevation="elevation"
:class="cn('w-full flex-row items-center gap-3.5 px-3.5 py-3 text-card-foreground', props.class)"
>
<slot />
</Card>
</template>
registry/components/ai/artifact-card/ArtifactCardActions.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { ChevronDown } from "@lucide/vue"
import { cn } from "@/registry/edmi/lib/utils"
import { Button } from "@/registry/edmi/ui/button"
import { ButtonGroup } from "@/registry/edmi/ui/button-group"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/registry/edmi/ui/dropdown-menu"
import { useArtifactCardState } from "./context"
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
/** Label of the primary half of the split button; the `label` slot overrides it. */
label?: string
}>(), {
label: "Download",
})
const emit = defineEmits<{
(e: "download"): void
}>()
const state = useArtifactCardState()
</script>
<template>
<!-- The default slot holds `DropdownMenuItem`s for the chevron half; no chevron without them. -->
<ButtonGroup
v-if="state !== 'generating'"
data-slot="ai-artifact-card-actions"
:class="cn('shrink-0', props.class)"
>
<Button variant="secondary" size="sm" type="button" @click="emit('download')">
<slot name="label">
{{ label }}
</slot>
</Button>
<DropdownMenu v-if="$slots.default">
<DropdownMenuTrigger as-child>
<Button variant="secondary" size="icon-sm" type="button" aria-label="More download options">
<ChevronDown class="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<slot />
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</template>
registry/components/ai/artifact-card/ArtifactCardBody.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-artifact-card-body" :class="cn('min-w-0 flex-1', props.class)">
<slot />
</div>
</template>
registry/components/ai/artifact-card/ArtifactCardIcon.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { ArtifactKind } from "./context"
import { cn } from "@/registry/edmi/lib/utils"
import ArtifactKindIcon from "./ArtifactKindIcon.vue"
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
kind?: ArtifactKind
}>(), {
kind: "document",
})
</script>
<template>
<span
data-slot="ai-artifact-card-icon"
:class="cn('inline-flex h-14 w-[52px] shrink-0 items-center justify-center rounded-[calc(var(--radius)*0.9)] border border-border bg-muted text-muted-foreground', props.class)"
>
<slot>
<ArtifactKindIcon :kind="kind" />
</slot>
</span>
</template>
registry/components/ai/artifact-card/ArtifactCardMeta.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-artifact-card-meta"
:class="cn('mt-[3px] font-mono text-[12.5px] text-muted-foreground', props.class)"
>
<slot />
</div>
</template>
registry/components/ai/artifact-card/ArtifactCardThumbnail.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
/** `paper`: cropped top of page 1 (always light). `slide`: dark slide. */
variant?: "paper" | "slide"
}>(), {
variant: "paper",
})
// Paper is always light (DESIGN 5b rule 5): literal page colors, not themed tokens.
const PAPER_LINES = [92, 85, 78, 71, 64, 87, 80, 73]
</script>
<template>
<span
data-slot="ai-artifact-card-thumbnail"
:data-variant="variant"
:class="cn('block h-14 w-[52px] shrink-0 overflow-hidden rounded-[calc(var(--radius)*0.9)] border border-border', props.class)"
>
<span
:class="cn(
'box-border block size-full overflow-hidden px-[6px] py-[7px]',
variant === 'paper' ? 'bg-white' : 'bg-[#14213d]',
)"
>
<slot>
<template v-if="variant === 'paper'">
<span class="block h-0.5 w-[40%] rounded-[2px] bg-[#d9d8d2]" />
<span class="mt-[3px] block h-1 w-[88%] rounded-[2px] bg-[#1f1f1d]" />
<span class="mt-[3px] block h-1 w-[63%] rounded-[2px] bg-[#1f1f1d]" />
<span
v-for="w in PAPER_LINES"
:key="w"
class="mt-[3px] block h-0.5 rounded-[2px] bg-[#d9d8d2]"
:style="{ width: `${w}%` }"
/>
</template>
<template v-else>
<span class="block h-0.5 w-[40%] rounded-[2px] bg-[#e39a3c]" />
<span class="mt-3 block h-1 w-[80%] rounded-[2px] bg-white" />
<span class="mt-[3px] block h-1 w-[56%] rounded-[2px] bg-white" />
</template>
</slot>
</span>
</span>
</template>
registry/components/ai/artifact-card/ArtifactCardTitle.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { computed, useSlots } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { Shimmer } from "@/registry/edmi/components/ai/shimmer"
import { useArtifactCardState } from "./context"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
const state = useArtifactCardState()
const slots = useSlots()
// Shimmer reads its text from the slot, so hand it the plain text of ours.
const text = computed(() =>
(slots.default?.() ?? [])
.map(v => (typeof v.children === "string" ? v.children : ""))
.join(""),
)
</script>
<template>
<div data-slot="ai-artifact-card-title" :class="cn('truncate text-[14.5px] font-medium', props.class)">
<Shimmer v-if="state === 'generating'" as="span">
{{ text }}
</Shimmer>
<slot v-else />
</div>
</template>
registry/components/ai/artifact-card/ArtifactKindIcon.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { ArtifactKind } from "./context"
import {
AppWindowIcon,
ArchiveIcon,
FileCodeIcon,
FileIcon,
FileTextIcon,
ImageIcon,
} from "@lucide/vue"
withDefaults(defineProps<{
kind?: ArtifactKind
class?: HTMLAttributes["class"]
}>(), {
kind: "document",
class: "size-5",
})
</script>
<template>
<ArchiveIcon v-if="kind === 'archive'" :class="$props.class" />
<FileCodeIcon v-else-if="kind === 'code'" :class="$props.class" />
<ImageIcon v-else-if="kind === 'image'" :class="$props.class" />
<AppWindowIcon v-else-if="kind === 'slides'" :class="$props.class" />
<FileIcon v-else-if="kind === 'file'" :class="$props.class" />
<FileTextIcon v-else :class="$props.class" />
</template>
registry/components/ai/artifact-card/context.ts
import type { ComputedRef, InjectionKey } from "vue";
import { computed, inject } from "vue";
export type ArtifactKind =
| "archive"
| "document"
| "code"
| "image"
| "slides"
| "file";
export type ArtifactCardState = "ready" | "generating";
export const ARTIFACT_CARD_KEY: InjectionKey<ComputedRef<ArtifactCardState>> =
Symbol("ArtifactCardState");
export function useArtifactCardState() {
return inject(
ARTIFACT_CARD_KEY,
computed<ArtifactCardState>(() => "ready"),
);
}
registry/components/ai/artifact-card/index.ts
export { default as ArtifactCard } from "./ArtifactCard.vue";
export { default as ArtifactCardActions } from "./ArtifactCardActions.vue";
export { default as ArtifactCardBody } from "./ArtifactCardBody.vue";
export { default as ArtifactCardIcon } from "./ArtifactCardIcon.vue";
export { default as ArtifactCardMeta } from "./ArtifactCardMeta.vue";
export { default as ArtifactCardThumbnail } from "./ArtifactCardThumbnail.vue";
export { default as ArtifactCardTitle } from "./ArtifactCardTitle.vue";
export { default as ArtifactKindIcon } from "./ArtifactKindIcon.vue";
export * from "./context";
src/lib/registry/ai/artifact-card/artifact-card-actions.svelte
<script lang="ts">
import CaretDownIcon from "phosphor-svelte/lib/CaretDown";
import { Button } from "$lib/registry/ui/button/index.js";
import { ButtonGroup } from "$lib/registry/ui/button-group/index.js";
import * as DropdownMenu from "$lib/registry/ui/dropdown-menu/index.js";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
import { getArtifactCardState } from "./use-artifact-card.svelte.js";
let {
ref = $bindable(null),
label = "Download",
onDownload,
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
/** Label of the primary half of the split button. */
label?: string | Snippet;
onDownload?: () => void;
/** `DropdownMenu.Item`s for the chevron half (copy link, open, ...). No chevron without them. */
children?: Snippet;
} = $props();
const card = getArtifactCardState();
</script>
<!-- Split Download button; hidden while the card is generating. -->
{#if card.state !== "generating"}
<ButtonGroup bind:ref data-slot="ai-artifact-card-actions" class={cn("shrink-0", className)} {...restProps}>
<Button variant="secondary" size="sm" type="button" onclick={() => onDownload?.()}>
{#if typeof label === "string"}{label}{:else}{@render label()}{/if}
</Button>
{#if children}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="secondary"
size="icon-sm"
type="button"
aria-label="More download options"
>
<CaretDownIcon
class="size-3.5"
/>
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{@render children()}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
</ButtonGroup>
{/if}
src/lib/registry/ai/artifact-card/artifact-card-body.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-artifact-card-body" class={cn("min-w-0 flex-1", className)} {...restProps}>
{@render children?.()}
</div>
src/lib/registry/ai/artifact-card/artifact-card-icon.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import ArtifactKindIcon from "./artifact-kind-icon.svelte";
import type { ArtifactKind } from "./use-artifact-card.svelte.js";
let {
ref = $bindable(null),
kind = "document",
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement> & { kind?: ArtifactKind } = $props();
</script>
<span
bind:this={ref}
data-slot="ai-artifact-card-icon"
class={cn(
"inline-flex h-14 w-[52px] shrink-0 items-center justify-center rounded-[calc(var(--radius)*0.9)] border border-border bg-muted text-muted-foreground",
className
)}
{...restProps}
>
{#if children}
{@render children()}
{:else}
<ArtifactKindIcon {kind} />
{/if}
</span>
src/lib/registry/ai/artifact-card/artifact-card-meta.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>
<!-- Meta line: kind and format (`Document · PDF`), or a status while generating. -->
<div
bind:this={ref}
data-slot="ai-artifact-card-meta"
class={cn("mt-[3px] font-mono text-[12.5px] text-muted-foreground", className)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ai/artifact-card/artifact-card-thumbnail.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
variant = "paper",
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement> & {
/** `paper`: cropped top of page 1 (always light). `slide`: dark slide. */
variant?: "paper" | "slide";
} = $props();
// Paper is always light (DESIGN 5b rule 5): literal page colors, not themed tokens.
const PAPER_LINES = [92, 85, 78, 71, 64, 87, 80, 73];
</script>
<span
bind:this={ref}
data-slot="ai-artifact-card-thumbnail"
data-variant={variant}
class={cn(
"block h-14 w-[52px] shrink-0 overflow-hidden rounded-[calc(var(--radius)*0.9)] border border-border",
className
)}
{...restProps}
>
<span
class={cn(
"box-border block size-full overflow-hidden px-[6px] py-[7px]",
variant === "paper" ? "bg-white" : "bg-[#14213d]"
)}
>
{#if children}
{@render children()}
{:else if variant === "paper"}
<span class="block h-0.5 w-[40%] rounded-[2px] bg-[#d9d8d2]"></span>
<span class="mt-[3px] block h-1 w-[88%] rounded-[2px] bg-[#1f1f1d]"></span>
<span class="mt-[3px] block h-1 w-[63%] rounded-[2px] bg-[#1f1f1d]"></span>
{#each PAPER_LINES as w (w)}
<span class="mt-[3px] block h-0.5 rounded-[2px] bg-[#d9d8d2]" style="width: {w}%"></span>
{/each}
{:else}
<span class="block h-0.5 w-[40%] rounded-[2px] bg-[#e39a3c]"></span>
<span class="mt-3 block h-1 w-[80%] rounded-[2px] bg-white"></span>
<span class="mt-[3px] block h-1 w-[56%] rounded-[2px] bg-white"></span>
{/if}
</span>
</span>
src/lib/registry/ai/artifact-card/artifact-card-title.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { Shimmer } from "../shimmer/index.js";
import { getArtifactCardState } from "./use-artifact-card.svelte.js";
let { ref = $bindable(null), class: className, children, ...restProps }: WithElementRef<HTMLAttributes<HTMLDivElement>, HTMLDivElement> = $props();
const card = getArtifactCardState();
</script>
<div
bind:this={ref}
data-slot="ai-artifact-card-title"
class={cn("truncate text-[14.5px] font-medium", className)}
{...restProps}
>
{#if card.state === "generating"}
<Shimmer as="span">{#if children}{@render children()}{/if}</Shimmer>
{:else}
{@render children?.()}
{/if}
</div>
src/lib/registry/ai/artifact-card/artifact-card.svelte
<script lang="ts">
import { Card } from "$lib/registry/ui/card/index.js";
import type { Elevation } from "$lib/registry/ui/elevation/index.js";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import { type ArtifactCardState, setArtifactCardState } from "./use-artifact-card.svelte.js";
let {
ref = $bindable(null),
class: className,
state = "ready",
elevation = "auto",
children,
...restProps
}: Omit<ComponentProps<typeof Card>, "elevation"> & {
/** `generating`: the title shimmers and the actions are hidden. */
state?: ArtifactCardState;
/** ✦ depth: sunken -1, flat 0, raised +1, floating +2. */
elevation?: Elevation;
} = $props();
setArtifactCardState(() => state);
</script>
<Card
bind:ref
data-slot="ai-artifact-card"
data-state={state}
{elevation}
class={cn("w-full flex-row items-center gap-3.5 px-3.5 py-3 text-card-foreground", className)}
{...restProps}
>
{@render children?.()}
</Card>
src/lib/registry/ai/artifact-card/artifact-kind-icon.svelte
<script lang="ts">
import ArchiveIcon from "phosphor-svelte/lib/Archive";
import FileCodeIcon from "phosphor-svelte/lib/FileCode";
import ImageIcon from "phosphor-svelte/lib/Image";
import AppWindowIcon from "phosphor-svelte/lib/AppWindow";
import FileIcon from "phosphor-svelte/lib/File";
import FileTextIcon from "phosphor-svelte/lib/FileText";
import type { ArtifactKind } from "./use-artifact-card.svelte.js";
let {
kind = "document",
class: className = "size-5",
}: {
kind?: ArtifactKind;
class?: string;
} = $props();
</script>
{#if kind === "archive"}
<ArchiveIcon
class={className}
/>
{:else if kind === "code"}
<FileCodeIcon
class={className}
/>
{:else if kind === "image"}
<ImageIcon
class={className}
/>
{:else if kind === "slides"}
<AppWindowIcon
class={className}
/>
{:else if kind === "file"}
<FileIcon
class={className}
/>
{:else}
<FileTextIcon
class={className}
/>
{/if}
src/lib/registry/ai/artifact-card/index.ts
import ArtifactCard from "./artifact-card.svelte";
import ArtifactCardActions from "./artifact-card-actions.svelte";
import ArtifactCardBody from "./artifact-card-body.svelte";
import ArtifactCardIcon from "./artifact-card-icon.svelte";
import ArtifactCardMeta from "./artifact-card-meta.svelte";
import ArtifactCardThumbnail from "./artifact-card-thumbnail.svelte";
import ArtifactCardTitle from "./artifact-card-title.svelte";
import ArtifactKindIcon from "./artifact-kind-icon.svelte";
export * from "./use-artifact-card.svelte.js";
export {
ArtifactCard,
ArtifactCardActions,
ArtifactCardBody,
ArtifactCardIcon,
ArtifactCardMeta,
ArtifactCardThumbnail,
ArtifactCardTitle,
ArtifactKindIcon,
};
src/lib/registry/ai/artifact-card/use-artifact-card.svelte.ts
import { getContext, setContext } from "svelte";
export type ArtifactKind =
| "archive"
| "document"
| "code"
| "image"
| "slides"
| "file";
export type ArtifactCardState = "ready" | "generating";
const KEY = Symbol("ArtifactCardState");
/** `state` is passed as a getter so the context stays reactive. */
export function setArtifactCardState(state: () => ArtifactCardState) {
setContext(KEY, {
get state() {
return state();
},
});
}
export function getArtifactCardState(): { readonly state: ArtifactCardState } {
return (
getContext<{ readonly state: ArtifactCardState } | undefined>(KEY) ?? {
state: "ready",
}
);
}

See the Elevation guide for the levels, roles and layered mode.

The card are flat by default and take the surface level in layered mode (raised). sunken (-1) is a soft inset, raised (+1) a bevel and floating (+2) a bevel with a soft drop.

StackBlitz ↗StackBlitz ↗StackBlitz ↗

Sunken (-1)

Keeper starter
ZIP

Flat (0)

Keeper starter
ZIP

Raised (+1)

Keeper starter
ZIP

Floating (+2)

Keeper starter
ZIP

Sunken (-1)

Keeper starter
ZIP

Flat (0)

Keeper starter
ZIP

Raised (+1)

Keeper starter
ZIP

Floating (+2)

Keeper starter
ZIP

Sunken (-1)

Keeper starter
ZIP

Flat (0)

Keeper starter
ZIP

Raised (+1)

Keeper starter
ZIP

Floating (+2)

Keeper starter
ZIP
Part Prop Type Default
ArtifactCard state ready | generating ready
ArtifactCard elevation "auto" | "sunken" | "flat" | "raised" | "floating" "auto"
ArtifactCardIcon kind archive | document | code | image | slides | file document
ArtifactCardThumbnail variant paper | slide paper
ArtifactCardTitle / Meta / Body div props
ArtifactCardActions label, onDownload ReactNode, () => void (Vue: label slot and download event) Download
ArtifactCardActions children DropdownMenuItems of the chevron half; no chevron without them
ArtifactKindIcon kind, className helper, also used by Session Panel

In Vue ReactNode props are slots of the same name (the string prop stays as a fallback); in Svelte they accept string | Snippet.

  • Split Download button; the chevron opens copy link, open and similar actions.
  • state="generating": the title shimmers and the actions are hidden.
  • ArtifactCardThumbnail: the top of page 1 as paper (always light, also in dark mode); variant="slide" is dark. It is an inset tile the size of the icon tile, clipped to its radius. Paper and slide colours are fixed by design (documents render as paper in every mode), so the thumbnail uses literal colours.