Artifact Card
File card for generated output: paper thumbnail, format meta, split Download and a generating state.
import { ArtifactCard, ArtifactCardActions, ArtifactCardBody, ArtifactCardIcon, ArtifactCardMeta, ArtifactCardTitle,} from "@/components/ai/artifact-card";import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
export default function Demo() { return ( <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>Edmi ✦ addition. Built on card, button, button-group, dropdown-menu and ai-shimmer.
Installation
Section titled “Installation”npx shadcn@latest add @edmi-ui/ai-artifact-cardpnpm dlx shadcn@latest add @edmi-ui/ai-artifact-cardyarn dlx shadcn@latest add @edmi-ui/ai-artifact-cardbunx shadcn@latest add @edmi-ui/ai-artifact-cardPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
card,button,button-group,dropdown-menu,ai-shimmer,elevation.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-vue@latest add @edmi-ui/ai-artifact-cardpnpm dlx shadcn-vue@latest add @edmi-ui/ai-artifact-cardyarn dlx shadcn-vue@latest add @edmi-ui/ai-artifact-cardbunx shadcn-vue@latest add @edmi-ui/ai-artifact-cardPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install @lucide/vueTerminal window pnpm add @lucide/vueTerminal window yarn add @lucide/vueTerminal window bun add @lucide/vueAdd the Edmi components this one builds on:
card,button,button-group,dropdown-menu,ai-shimmer,elevation.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-artifact-card.jsonpnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-artifact-card.jsonyarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-artifact-card.jsonbunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-artifact-card.jsonPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
card,button,button-group,dropdown-menu,ai-shimmer,elevation.Copy the code from Component source into your project and update the import paths to match your aliases.
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>Component source
Section titled “Component source”"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> );};<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><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><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><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><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><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><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><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>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"), );}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";<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}<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><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><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><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><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><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><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}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,};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", } );}Elevation ✦
Section titled “Elevation ✦”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.
Sunken (-1)
Flat (0)
Raised (+1)
Floating (+2)
Sunken (-1)
Flat (0)
Raised (+1)
Floating (+2)
Sunken (-1)
Flat (0)
Raised (+1)
Floating (+2)
import { ArtifactCard, ArtifactCardActions, ArtifactCardBody, ArtifactCardIcon, ArtifactCardMeta, ArtifactCardTitle,} from "@/components/ai/artifact-card";import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
const levels = [ { value: "sunken", label: "Sunken (-1)" }, { value: "flat", label: "Flat (0)" }, { value: "raised", label: "Raised (+1)" }, { value: "floating", label: "Floating (+2)" },] as const;
export default function Demo() { return ( <div className="flex w-full max-w-2xl flex-col gap-5"> {levels.map(({ value, label }) => ( <div key={value} className="flex flex-col gap-2"> <p className="text-xs font-medium text-muted-foreground">{label}</p> <ArtifactCard elevation={value} className="max-w-lg"> <ArtifactCardIcon kind="archive" /> <ArtifactCardBody> <ArtifactCardTitle>Keeper starter</ArtifactCardTitle> <ArtifactCardMeta>ZIP</ArtifactCardMeta> </ArtifactCardBody> <ArtifactCardActions> <DropdownMenuItem>Copy link</DropdownMenuItem> <DropdownMenuItem>Open</DropdownMenuItem> </ArtifactCardActions> </ArtifactCard> </div> ))} </div> );}<script setup lang="ts">import { ArtifactCard, ArtifactCardActions, ArtifactCardBody, ArtifactCardIcon, ArtifactCardMeta, ArtifactCardTitle,} from "@/components/ai/artifact-card";import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
const levels = [{ value: "sunken", label: "Sunken (-1)" }, { value: "flat", label: "Flat (0)" }, { value: "raised", label: "Raised (+1)" }, { value: "floating", label: "Floating (+2)" }];</script>
<template> <div class="flex w-full max-w-2xl flex-col gap-5"> <div v-for="level in levels" :key="level.value" class="flex flex-col gap-2"> <p class="text-xs font-medium text-muted-foreground">{{ level.label }}</p> <ArtifactCard :elevation="level.value" class="max-w-lg"> <ArtifactCardIcon kind="archive" /> <ArtifactCardBody> <ArtifactCardTitle>Keeper starter</ArtifactCardTitle> <ArtifactCardMeta>ZIP</ArtifactCardMeta> </ArtifactCardBody> <ArtifactCardActions> <DropdownMenuItem>Copy link</DropdownMenuItem> <DropdownMenuItem>Open</DropdownMenuItem> </ArtifactCardActions> </ArtifactCard> </div> </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";
const levels = [{ value: "sunken", label: "Sunken (-1)" }, { value: "flat", label: "Flat (0)" }, { value: "raised", label: "Raised (+1)" }, { value: "floating", label: "Floating (+2)" }] as const;</script>
<div class="flex w-full max-w-2xl flex-col gap-5"> {#each levels as level (level.value)} <div class="flex flex-col gap-2"> <p class="text-xs font-medium text-muted-foreground">{level.label}</p> <ArtifactCard elevation={level.value} class="max-w-lg"> <ArtifactCardIcon kind="archive" /> <ArtifactCardBody> <ArtifactCardTitle>Keeper starter</ArtifactCardTitle> <ArtifactCardMeta>ZIP</ArtifactCardMeta> </ArtifactCardBody> <ArtifactCardActions> <DropdownMenuItem>Copy link</DropdownMenuItem> <DropdownMenuItem>Open</DropdownMenuItem> </ArtifactCardActions> </ArtifactCard> </div> {/each}</div>| 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.
✦ Edmi additions
Section titled “✦ Edmi additions”- 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.