Skip to content
edmi-ui

Artifact Viewer

Side panel that shows one artifact: title and format, open-in, download, expand and close; documents render as paper.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Rebalance report · PDF
RESEARCH · MAG4 · OCT 2026
Drift stays inside the band

The keeper checks weights every hour and trades only when a sleeve drifts past 2%. Slippage stays under 1% at current depth. No manual step is needed before the next rebalance.

What changed this week
Rebalance report · PDF
RESEARCH · MAG4 · OCT 2026
Drift stays inside the band

The keeper checks weights every hour and trades only when a sleeve drifts past 2%. Slippage stays under 1% at current depth. No manual step is needed before the next rebalance.

What changed this week
Rebalance report · PDF
RESEARCH · MAG4 · OCT 2026
Drift stays inside the band

The keeper checks weights every hour and trades only when a sleeve drifts past 2%. Slippage stays under 1% at current depth. No manual step is needed before the next rebalance.

What changed this week

Edmi ✦ addition. Built on card, button and tooltip.

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

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

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

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

import {
ArtifactViewer,
ArtifactViewerClose,
ArtifactViewerDownload,
ArtifactViewerExpand,
ArtifactViewerHeader,
ArtifactViewerOpenIn,
ArtifactViewerTitle,
} from "@/components/ai/artifact-viewer";
<ArtifactViewer
>
<ArtifactViewerHeader>
<ArtifactViewerTitle format="PDF">Rebalance report</ArtifactViewerTitle>
<ArtifactViewerOpenIn />
<ArtifactViewerDownload />
<ArtifactViewerExpand />
<ArtifactViewerClose />
</ArtifactViewerHeader>
{/* … */}
</ArtifactViewer>
<script setup lang="ts">
import { Button } from "@/components/ui/button";
</script>
<template>
<Button size="sm" variant="outline">Show artifact</Button>
<!-- … -->
</template>
<script lang="ts">
import { Button } from "$lib/components/ui/button";
</script>
{#if !open}
<Button size="sm" variant="outline">Show artifact</Button>
{:else}
<!-- … -->
registry/components/ai/artifact-viewer.tsx
import { cn } from "cn";
import type { ComponentProps, ReactNode } from "react";
import { ArrowUpRightIcon, DownloadIcon, ArrowsOutSimpleIcon, XIcon } from "@phosphor-icons/react";
import { Button } from "@/registry/edmi/ui/button";
import { Card } from "@/registry/edmi/ui/card";
import type { Elevation } from "@/registry/edmi/ui/elevation";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/registry/edmi/ui/tooltip";
export type ArtifactViewerProps = Omit<
ComponentProps<typeof Card>,
"elevation"
> & {
/** ✦ depth: sunken -1, flat 0, raised +1, floating +2. */
elevation?: Elevation;
};
/** Side panel that shows one artifact. */
export const ArtifactViewer = ({
className,
elevation,
...props
}: ArtifactViewerProps) => (
<Card
data-slot="ai-artifact-viewer"
elevation={elevation}
className={cn("gap-0 py-0 text-card-foreground", className)}
{...props}
/>
);
export type ArtifactViewerHeaderProps = ComponentProps<"div">;
export const ArtifactViewerHeader = ({
className,
...props
}: ArtifactViewerHeaderProps) => (
<div
data-slot="ai-artifact-viewer-header"
className={cn(
"flex h-[46px] shrink-0 items-center gap-1 border-b border-border pr-2 pl-4",
className,
)}
{...props}
/>
);
export type ArtifactViewerTitleProps = ComponentProps<"span"> & {
/** Format shown after the title in mono (`PDF`). */
format?: ReactNode;
};
export const ArtifactViewerTitle = ({
className,
children,
format,
...props
}: ArtifactViewerTitleProps) => (
<span
data-slot="ai-artifact-viewer-title"
className={cn("min-w-0 flex-1 truncate text-sm", className)}
{...props}
>
{children}
{format && (
<span className="font-mono text-muted-foreground"> · {format}</span>
)}
</span>
);
export type ArtifactViewerActionProps = ComponentProps<typeof Button> & {
/** Tooltip and accessible name. */
label: string;
};
export const ArtifactViewerAction = ({
label,
children,
...props
}: ArtifactViewerActionProps) => (
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label={label}
size="icon-sm"
type="button"
variant="ghost"
{...props}
/>
}
>
{children}
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
type PresetProps = Omit<ArtifactViewerActionProps, "label" | "children"> & {
label?: string;
};
export const ArtifactViewerOpenIn = ({
label = "Open in",
...props
}: PresetProps) => (
<ArtifactViewerAction label={label} {...props}>
<ArrowUpRightIcon
className="size-4"
/>
</ArtifactViewerAction>
);
export const ArtifactViewerDownload = ({
label = "Download",
...props
}: PresetProps) => (
<ArtifactViewerAction label={label} {...props}>
<DownloadIcon
className="size-4"
/>
</ArtifactViewerAction>
);
export const ArtifactViewerExpand = ({
label = "Expand",
...props
}: PresetProps) => (
<ArtifactViewerAction label={label} {...props}>
<ArrowsOutSimpleIcon
className="size-4"
/>
</ArtifactViewerAction>
);
export const ArtifactViewerClose = ({
label = "Close",
...props
}: PresetProps) => (
<ArtifactViewerAction label={label} {...props}>
<XIcon
className="size-4"
/>
</ArtifactViewerAction>
);
export type ArtifactViewerContentProps = ComponentProps<"div">;
/** Scroll area on `--muted`; documents sit on it as paper. */
export const ArtifactViewerContent = ({
className,
...props
}: ArtifactViewerContentProps) => (
<div
data-slot="ai-artifact-viewer-content"
className={cn("min-h-0 flex-1 overflow-auto bg-muted p-4", className)}
{...props}
/>
);
export type ArtifactViewerPaperProps = ComponentProps<"div">;
/** A white page, in light and dark mode alike (DESIGN 5b rule 5). */
export const ArtifactViewerPaper = ({
className,
...props
}: ArtifactViewerPaperProps) => (
<div
data-slot="ai-artifact-viewer-paper"
className={cn(
"w-full rounded-[calc(var(--radius)*0.6)] border border-border bg-white px-10 py-[34px] text-[#1f1f1d]",
className,
)}
{...props}
/>
);
registry/components/ai/artifact-viewer/ArtifactViewer.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { Card } from "@/registry/edmi/ui/card"
import type { Elevation } from "@/registry/edmi/ui/elevation"
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
/** ✦ depth: sunken -1, flat 0, raised +1, floating +2. */
elevation?: Elevation
}>(), {
elevation: undefined,
})
</script>
<template>
<Card data-slot="ai-artifact-viewer" :elevation="elevation" :class="cn('gap-0 py-0 text-card-foreground', props.class)">
<slot />
</Card>
</template>
registry/components/ai/artifact-viewer/ArtifactViewerAction.vue
<script setup lang="ts">
import { Button } from "@/registry/edmi/ui/button"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/registry/edmi/ui/tooltip"
// Listeners and classes (`@click`, `class`) land on the button, not on the tooltip root.
defineOptions({ inheritAttrs: false })
defineProps<{
/** Tooltip and accessible name. */
label: string
}>()
</script>
<template>
<!-- Own provider so the viewer works without a global TooltipProvider. -->
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button v-bind="$attrs" :aria-label="label" size="icon-sm" type="button" variant="ghost">
<slot />
</Button>
</TooltipTrigger>
<TooltipContent>{{ label }}</TooltipContent>
</Tooltip>
</TooltipProvider>
</template>
registry/components/ai/artifact-viewer/ArtifactViewerClose.vue
<script setup lang="ts">
import { X } from "@lucide/vue"
import ArtifactViewerAction from "./ArtifactViewerAction.vue"
withDefaults(defineProps<{
label?: string
}>(), {
label: "Close",
})
</script>
<template>
<ArtifactViewerAction :label="label">
<X class="size-4" />
</ArtifactViewerAction>
</template>
registry/components/ai/artifact-viewer/ArtifactViewerContent.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>
<!-- Scroll area on --muted; documents sit on it as paper. -->
<div data-slot="ai-artifact-viewer-content" :class="cn('min-h-0 flex-1 overflow-auto bg-muted p-4', props.class)">
<slot />
</div>
</template>
registry/components/ai/artifact-viewer/ArtifactViewerDownload.vue
<script setup lang="ts">
import { DownloadIcon } from "@lucide/vue"
import ArtifactViewerAction from "./ArtifactViewerAction.vue"
withDefaults(defineProps<{
label?: string
}>(), {
label: "Download",
})
</script>
<template>
<ArtifactViewerAction :label="label">
<DownloadIcon class="size-4" />
</ArtifactViewerAction>
</template>
registry/components/ai/artifact-viewer/ArtifactViewerExpand.vue
<script setup lang="ts">
import { MaximizeIcon } from "@lucide/vue"
import ArtifactViewerAction from "./ArtifactViewerAction.vue"
withDefaults(defineProps<{
label?: string
}>(), {
label: "Expand",
})
</script>
<template>
<ArtifactViewerAction :label="label">
<MaximizeIcon class="size-4" />
</ArtifactViewerAction>
</template>
registry/components/ai/artifact-viewer/ArtifactViewerHeader.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-viewer-header"
:class="cn('flex h-[46px] shrink-0 items-center gap-1 border-b border-border pr-2 pl-4', props.class)"
>
<slot />
</div>
</template>
registry/components/ai/artifact-viewer/ArtifactViewerOpenIn.vue
<script setup lang="ts">
import { ArrowUpRightIcon } from "@lucide/vue"
import ArtifactViewerAction from "./ArtifactViewerAction.vue"
withDefaults(defineProps<{
label?: string
}>(), {
label: "Open in",
})
</script>
<template>
<ArtifactViewerAction :label="label">
<ArrowUpRightIcon class="size-4" />
</ArtifactViewerAction>
</template>
registry/components/ai/artifact-viewer/ArtifactViewerPaper.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>
<!-- A white page, in light and dark mode alike (DESIGN 5b rule 5). -->
<div
data-slot="ai-artifact-viewer-paper"
:class="cn('w-full rounded-[calc(var(--radius)*0.6)] border border-border bg-white px-10 py-[34px] text-[#1f1f1d]', props.class)"
>
<slot />
</div>
</template>
registry/components/ai/artifact-viewer/ArtifactViewerTitle.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
/** Format shown after the title in mono (`PDF`); the `format` slot overrides it. */
format?: string
}>()
</script>
<template>
<span data-slot="ai-artifact-viewer-title" :class="cn('min-w-0 flex-1 truncate text-sm', props.class)">
<slot />
<span v-if="format || $slots.format" class="font-mono text-muted-foreground"> · <slot name="format">{{ format }}</slot></span>
</span>
</template>
registry/components/ai/artifact-viewer/index.ts
export { default as ArtifactViewer } from "./ArtifactViewer.vue";
export { default as ArtifactViewerAction } from "./ArtifactViewerAction.vue";
export { default as ArtifactViewerClose } from "./ArtifactViewerClose.vue";
export { default as ArtifactViewerContent } from "./ArtifactViewerContent.vue";
export { default as ArtifactViewerDownload } from "./ArtifactViewerDownload.vue";
export { default as ArtifactViewerExpand } from "./ArtifactViewerExpand.vue";
export { default as ArtifactViewerHeader } from "./ArtifactViewerHeader.vue";
export { default as ArtifactViewerOpenIn } from "./ArtifactViewerOpenIn.vue";
export { default as ArtifactViewerPaper } from "./ArtifactViewerPaper.vue";
export { default as ArtifactViewerTitle } from "./ArtifactViewerTitle.vue";
src/lib/registry/ai/artifact-viewer/artifact-viewer-action.svelte
<script lang="ts">
import { Button, type ButtonProps } from "$lib/registry/ui/button/index.js";
import * as Tooltip from "$lib/registry/ui/tooltip/index.js";
let {
ref = $bindable(null),
label,
children,
...restProps
}: Omit<ButtonProps, "href"> & {
/** Tooltip and accessible name. */
label: string;
} = $props();
</script>
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<Button
bind:ref
{...props}
{...restProps}
aria-label={label}
size="icon-sm"
type="button"
variant="ghost"
>
{@render children?.()}
</Button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>{label}</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
src/lib/registry/ai/artifact-viewer/artifact-viewer-close.svelte
<script lang="ts">
import XIcon from "phosphor-svelte/lib/X";
import type { ComponentProps } from "svelte";
import ArtifactViewerAction from "./artifact-viewer-action.svelte";
let {
label = "Close",
...restProps
}: Omit<ComponentProps<typeof ArtifactViewerAction>, "label" | "children"> & {
label?: string;
} = $props();
</script>
<ArtifactViewerAction {label} {...restProps}>
<XIcon
class="size-4"
/>
</ArtifactViewerAction>
src/lib/registry/ai/artifact-viewer/artifact-viewer-content.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>
<!-- Scroll area on --muted; documents sit on it as paper. -->
<div
bind:this={ref}
data-slot="ai-artifact-viewer-content"
class={cn("min-h-0 flex-1 overflow-auto bg-muted p-4", className)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ai/artifact-viewer/artifact-viewer-download.svelte
<script lang="ts">
import DownloadIcon from "phosphor-svelte/lib/Download";
import type { ComponentProps } from "svelte";
import ArtifactViewerAction from "./artifact-viewer-action.svelte";
let {
label = "Download",
...restProps
}: Omit<ComponentProps<typeof ArtifactViewerAction>, "label" | "children"> & {
label?: string;
} = $props();
</script>
<ArtifactViewerAction {label} {...restProps}>
<DownloadIcon
class="size-4"
/>
</ArtifactViewerAction>
src/lib/registry/ai/artifact-viewer/artifact-viewer-expand.svelte
<script lang="ts">
import ArrowsOutSimpleIcon from "phosphor-svelte/lib/ArrowsOutSimple";
import type { ComponentProps } from "svelte";
import ArtifactViewerAction from "./artifact-viewer-action.svelte";
let {
label = "Expand",
...restProps
}: Omit<ComponentProps<typeof ArtifactViewerAction>, "label" | "children"> & {
label?: string;
} = $props();
</script>
<ArtifactViewerAction {label} {...restProps}>
<ArrowsOutSimpleIcon
class="size-4"
/>
</ArtifactViewerAction>
src/lib/registry/ai/artifact-viewer/artifact-viewer-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<HTMLDivElement>, HTMLDivElement> = $props();
</script>
<div
bind:this={ref}
data-slot="ai-artifact-viewer-header"
class={cn(
"flex h-[46px] shrink-0 items-center gap-1 border-b border-border pr-2 pl-4",
className
)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ai/artifact-viewer/artifact-viewer-open-in.svelte
<script lang="ts">
import ArrowUpRightIcon from "phosphor-svelte/lib/ArrowUpRight";
import type { ComponentProps } from "svelte";
import ArtifactViewerAction from "./artifact-viewer-action.svelte";
let {
label = "Open in",
...restProps
}: Omit<ComponentProps<typeof ArtifactViewerAction>, "label" | "children"> & {
label?: string;
} = $props();
</script>
<ArtifactViewerAction {label} {...restProps}>
<ArrowUpRightIcon
class="size-4"
/>
</ArtifactViewerAction>
src/lib/registry/ai/artifact-viewer/artifact-viewer-paper.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>
<!-- A white page, in light and dark mode alike (DESIGN 5b rule 5). -->
<div
bind:this={ref}
data-slot="ai-artifact-viewer-paper"
class={cn(
"w-full rounded-[calc(var(--radius)*0.6)] border border-border bg-white px-10 py-[34px] text-[#1f1f1d]",
className
)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ai/artifact-viewer/artifact-viewer-title.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
format,
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement> & {
/** Format shown after the title in mono (`PDF`). */
format?: string | Snippet;
} = $props();
</script>
<span
bind:this={ref}
data-slot="ai-artifact-viewer-title"
class={cn("min-w-0 flex-1 truncate text-sm", className)}
{...restProps}
>
{@render children?.()}
{#if format}
<span class="font-mono text-muted-foreground"> · {#if typeof format === "string"}{format}{:else}{@render format()}{/if}</span>
{/if}
</span>
src/lib/registry/ai/artifact-viewer/artifact-viewer.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";
let {
ref = $bindable(null),
class: className,
children,
elevation = "auto",
...restProps
}: Omit<ComponentProps<typeof Card>, "elevation"> & {
/** ✦ depth: sunken -1, flat 0, raised +1, floating +2. */
elevation?: Elevation;
} = $props();
</script>
<Card
bind:ref
data-slot="ai-artifact-viewer"
{elevation}
class={cn("gap-0 py-0 text-card-foreground", className)}
{...restProps}
>
{@render children?.()}
</Card>
src/lib/registry/ai/artifact-viewer/index.ts
import ArtifactViewer from "./artifact-viewer.svelte";
import ArtifactViewerAction from "./artifact-viewer-action.svelte";
import ArtifactViewerClose from "./artifact-viewer-close.svelte";
import ArtifactViewerContent from "./artifact-viewer-content.svelte";
import ArtifactViewerDownload from "./artifact-viewer-download.svelte";
import ArtifactViewerExpand from "./artifact-viewer-expand.svelte";
import ArtifactViewerHeader from "./artifact-viewer-header.svelte";
import ArtifactViewerOpenIn from "./artifact-viewer-open-in.svelte";
import ArtifactViewerPaper from "./artifact-viewer-paper.svelte";
import ArtifactViewerTitle from "./artifact-viewer-title.svelte";
export {
ArtifactViewer,
ArtifactViewerAction,
ArtifactViewerClose,
ArtifactViewerContent,
ArtifactViewerDownload,
ArtifactViewerExpand,
ArtifactViewerHeader,
ArtifactViewerOpenIn,
ArtifactViewerPaper,
ArtifactViewerTitle,
};

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

Flat by default and a surface in layered mode. sunken (-1) is a soft inset, raised (+1) a bevel and floating (+2) a bevel with a soft drop. The paper inside never changes.

StackBlitz ↗StackBlitz ↗StackBlitz ↗

Sunken (-1)

Rebalance report · PDF
Drift stays inside the band.

Flat (0)

Rebalance report · PDF
Drift stays inside the band.

Raised (+1)

Rebalance report · PDF
Drift stays inside the band.

Floating (+2)

Rebalance report · PDF
Drift stays inside the band.

Sunken (-1)

Rebalance report · PDF
Drift stays inside the band.

Flat (0)

Rebalance report · PDF
Drift stays inside the band.

Raised (+1)

Rebalance report · PDF
Drift stays inside the band.

Floating (+2)

Rebalance report · PDF
Drift stays inside the band.

Sunken (-1)

Rebalance report · PDF
Drift stays inside the band.

Flat (0)

Rebalance report · PDF
Drift stays inside the band.

Raised (+1)

Rebalance report · PDF
Drift stays inside the band.

Floating (+2)

Rebalance report · PDF
Drift stays inside the band.
Part Prop Type Default
ArtifactViewer elevation, Card props "auto" | "sunken" | "flat" | "raised" | "floating" "auto"
ArtifactViewerTitle format ReactNode (shown after the title in mono)
ArtifactViewerAction label string (tooltip and accessible name)
ArtifactViewerOpenIn / Download / Expand / Close label, Button props string
ArtifactViewerContent scroll area on --muted
ArtifactViewerPaper white page, same in every mode

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

  • Documents render as paper (white page) on the muted body in light and dark mode (DESIGN 5b rule 5). The paper colours are fixed by design (a document renders as paper in every mode), so they are literal colours and not tokens.