Skip to content
edmi-ui

Web Preview

Browser frame with navigation, URL bar, iframe body and console.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
14:02:11 log  Mounted IndexPage
14:02:12 warn Missing key prop in list
Generating preview…
14:02:11 log  Mounted IndexPage
14:02:12 warn Missing key prop in list
Generating preview…
14:02:11 log  Mounted IndexPage
14:02:12 warn Missing key prop in list
Generating preview…

Built on button, input, tooltip and collapsible.

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

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

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

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

import {
WebPreviewNavigation,
WebPreviewNavigationButton,
WebPreviewUrl,
} from "@/components/ai/web-preview";
<WebPreviewNavigation>
<WebPreviewNavigationButton tooltip="Back">
<CaretLeftIcon className="size-4" />
</WebPreviewNavigationButton>
<WebPreviewNavigationButton tooltip="Forward">
<CaretRightIcon className="size-4" />
</WebPreviewNavigationButton>
<WebPreviewNavigationButton tooltip="Reload">
<ArrowClockwiseIcon className="size-4" />
</WebPreviewNavigationButton>
<WebPreviewUrl />
<WebPreviewNavigationButton tooltip="Open in new tab">
<ArrowSquareOutIcon className="size-4" />
</WebPreviewNavigationButton>
</WebPreviewNavigation>
<script setup lang="ts">
import {
WebPreview,
WebPreviewBody,
WebPreviewConsole,
WebPreviewNavigation,
WebPreviewNavigationButton,
WebPreviewUrl,
} from "@/components/ai/web-preview";
</script>
<template>
<div class="flex w-full max-w-2xl flex-col gap-5">
<WebPreview default-console-open default-url="localhost:3000/indexes/mag4">
<WebPreviewNavigation>
<WebPreviewNavigationButton tooltip="Back"><ChevronLeftIcon class="size-4" /></WebPreviewNavigationButton>
<WebPreviewNavigationButton tooltip="Forward"><ChevronRightIcon class="size-4" /></WebPreviewNavigationButton>
<WebPreviewNavigationButton tooltip="Reload"><RotateCwIcon class="size-4" /></WebPreviewNavigationButton>
<WebPreviewUrl />
<WebPreviewNavigationButton tooltip="Open in new tab"><ArrowUpRightIcon class="size-4" /></WebPreviewNavigationButton>
</WebPreviewNavigation>
<WebPreviewBody class="h-[220px]" />
<WebPreviewConsole />
</WebPreview>
<!-- … -->
</div>
</template>
<script lang="ts">
import {
WebPreview,
WebPreviewBody,
WebPreviewConsole,
} from "$lib/components/ai/web-preview";
</script>
<div class="flex w-full max-w-2xl flex-col gap-5">
<WebPreview url="localhost:3000/indexes/mag4" consoleOpen>
<WebPreviewBody class="h-[220px]" />
<WebPreviewConsole />
</WebPreview>
<WebPreview url="localhost:3000/indexes/mag4">
<WebPreviewBody class="h-[220px]" src="about:blank" />
</WebPreview>
</div>
registry/components/ai/web-preview.tsx
"use client";
import { cn } from "cn";
import type { ComponentProps, ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
} from "react";
import { LockKeyIcon, CaretDownIcon } from "@phosphor-icons/react";
import { Button } from "@/registry/edmi/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/registry/edmi/ui/collapsible";
import { Input } from "@/registry/edmi/ui/input";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/registry/edmi/ui/tooltip";
export interface WebPreviewContextValue {
url: string;
setUrl: (url: string) => void;
consoleOpen: boolean;
setConsoleOpen: (open: boolean) => void;
}
const WebPreviewContext = createContext<WebPreviewContextValue | null>(null);
const useWebPreview = () => {
const context = useContext(WebPreviewContext);
if (!context) {
throw new Error("WebPreview components must be used within a WebPreview");
}
return context;
};
export type WebPreviewProps = ComponentProps<"div"> & {
defaultUrl?: string;
onUrlChange?: (url: string) => void;
/** ✦ Start with the console open. */
defaultConsoleOpen?: boolean;
};
export const WebPreview = ({
className,
children,
defaultUrl = "",
onUrlChange,
defaultConsoleOpen = false,
...props
}: WebPreviewProps) => {
const [url, setUrl] = useState(defaultUrl);
const [consoleOpen, setConsoleOpen] = useState(defaultConsoleOpen);
const handleUrlChange = useCallback(
(newUrl: string) => {
setUrl(newUrl);
onUrlChange?.(newUrl);
},
[onUrlChange],
);
const contextValue = useMemo<WebPreviewContextValue>(
() => ({
consoleOpen,
setConsoleOpen,
setUrl: handleUrlChange,
url,
}),
[consoleOpen, handleUrlChange, url],
);
return (
<WebPreviewContext.Provider value={contextValue}>
<div
data-slot="ai-web-preview"
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl border border-border bg-card",
className,
)}
{...props}
>
{children}
</div>
</WebPreviewContext.Provider>
);
};
export type WebPreviewNavigationProps = ComponentProps<"div">;
export const WebPreviewNavigation = ({
className,
children,
...props
}: WebPreviewNavigationProps) => (
<div
data-slot="ai-web-preview-navigation"
className={cn(
"flex h-11 shrink-0 items-center gap-1 border-b border-border px-2",
className,
)}
{...props}
>
{children}
</div>
);
export type WebPreviewNavigationButtonProps = ComponentProps<typeof Button> & {
tooltip?: string;
};
export const WebPreviewNavigationButton = ({
onClick,
disabled,
tooltip,
children,
...props
}: WebPreviewNavigationButtonProps) => {
const button = (
<Button
aria-label={tooltip}
disabled={disabled}
onClick={onClick}
size="icon-sm"
variant="ghost"
{...props}
>
{children}
</Button>
);
if (!tooltip) {
return button;
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={button} />
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
export type WebPreviewUrlProps = ComponentProps<typeof Input>;
export const WebPreviewUrl = ({
value,
onChange,
onKeyDown,
className,
...props
}: WebPreviewUrlProps) => {
const { url, setUrl } = useWebPreview();
const [prevUrl, setPrevUrl] = useState(url);
const [inputValue, setInputValue] = useState(url);
// Sync input value with context URL when it changes externally (derived state pattern)
if (url !== prevUrl) {
setPrevUrl(url);
setInputValue(url);
}
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
onChange?.(event);
};
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
const target = event.target as HTMLInputElement;
setUrl(target.value);
}
onKeyDown?.(event);
},
[setUrl, onKeyDown],
);
return (
<div className="relative mx-1.5 flex-1">
<LockKeyIcon
className="pointer-events-none absolute top-1/2 left-2.5 size-3 -translate-y-1/2 text-muted-foreground"
/>
<Input
data-slot="ai-web-preview-url"
className={cn("h-[30px] pl-7 font-mono text-xs", className)}
onChange={onChange ?? handleChange}
onKeyDown={handleKeyDown}
placeholder="Enter URL..."
value={value ?? inputValue}
{...props}
/>
</div>
);
};
export type WebPreviewBodyProps = Omit<ComponentProps<"iframe">, "loading"> & {
loading?: ReactNode;
};
export const WebPreviewBody = ({
className,
loading,
src,
...props
}: WebPreviewBodyProps) => {
const { url } = useWebPreview();
return (
<div
data-slot="ai-web-preview-body"
className="relative min-h-0 flex-1 bg-background"
>
<iframe
className={cn("size-full", className)}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation"
src={(src ?? url) || undefined}
title="Preview"
{...props}
/>
{loading}
</div>
);
};
export type WebPreviewConsoleProps = ComponentProps<"div"> & {
logs?: {
level: "log" | "warn" | "error";
message: string;
timestamp: Date;
}[];
};
export const WebPreviewConsole = ({
className,
logs = [],
children,
...props
}: WebPreviewConsoleProps) => {
const { consoleOpen, setConsoleOpen } = useWebPreview();
return (
<Collapsible
data-slot="ai-web-preview-console"
className={cn(
"border-t border-border bg-card font-mono text-[11.5px] leading-[1.8]",
className,
)}
onOpenChange={setConsoleOpen}
open={consoleOpen}
{...props}
>
<CollapsibleTrigger className="group/ai-console flex w-full items-center justify-between px-3 pt-2 pb-1 text-left font-mono text-[11px] font-medium tracking-[0.8px] text-muted-foreground uppercase outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring">
Console
<CaretDownIcon
className="size-3.5 transition-transform duration-200 group-data-[panel-open]/ai-console:rotate-180"
/>
</CollapsibleTrigger>
<CollapsibleContent className="h-(--collapsible-panel-height) overflow-hidden transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0">
<div className="max-h-48 overflow-y-auto px-3 pb-2">
{logs.length === 0 ? (
<p className="text-muted-foreground">No console output</p>
) : (
logs.map((log) => (
<div
className={cn(
log.level === "error" && "text-destructive-text",
log.level === "warn" && "text-warning-text",
log.level === "log" && "text-foreground",
)}
key={`${log.timestamp.getTime()}-${log.level}-${log.message}`}
>
<span className="text-muted-foreground">
{log.timestamp.toLocaleTimeString([], { hour12: false })}
</span>{" "}
{log.level.padEnd(4, " ")} {log.message}
</div>
))
)}
{children}
</div>
</CollapsibleContent>
</Collapsible>
);
};
registry/components/ai/web-preview/WebPreview.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { ref } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { provideWebPreviewContext } from "./context"
const props = withDefaults(defineProps<{
defaultUrl?: string
/** ✦ Start with the console open. */
defaultConsoleOpen?: boolean
class?: HTMLAttributes["class"]
}>(), {
defaultUrl: "",
defaultConsoleOpen: false,
})
const emit = defineEmits<{
(e: "update:url", value: string): void
(e: "urlChange", value: string): void
(e: "update:consoleOpen", value: boolean): void
(e: "consoleOpenChange", value: boolean): void
}>()
const url = ref(props.defaultUrl)
const consoleOpen = ref(props.defaultConsoleOpen)
function setUrl(value: string) {
url.value = value
emit("update:url", value)
emit("urlChange", value)
}
function setConsoleOpen(value: boolean) {
consoleOpen.value = value
emit("update:consoleOpen", value)
emit("consoleOpenChange", value)
}
provideWebPreviewContext({
url,
setUrl,
consoleOpen,
setConsoleOpen,
})
</script>
<template>
<div
data-slot="ai-web-preview"
:class="cn('flex size-full flex-col overflow-hidden rounded-xl border border-border bg-card', props.class)"
>
<slot />
</div>
</template>
registry/components/ai/web-preview/WebPreviewBody.vue
<script setup lang="ts">
import type { IframeHTMLAttributes, VNodeChild } from "vue"
import { computed } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { useWebPreviewContext } from "./context"
defineOptions({ inheritAttrs: false })
const props = defineProps<{
class?: IframeHTMLAttributes["class"]
src?: string
}>()
defineSlots<{
loading?: () => VNodeChild
}>()
const { url } = useWebPreviewContext()
const frameSrc = computed(() => (props.src ?? url.value) || undefined)
</script>
<template>
<div data-slot="ai-web-preview-body" class="relative min-h-0 flex-1 bg-background">
<iframe
:class="cn('size-full', props.class)"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation"
:src="frameSrc"
title="Preview"
v-bind="$attrs"
/>
<slot name="loading" />
</div>
</template>
registry/components/ai/web-preview/WebPreviewConsole.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { ChevronDownIcon } from "@lucide/vue"
import { cn } from "@/registry/edmi/lib/utils"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/registry/edmi/ui/collapsible"
import { useWebPreviewContext } from "./context"
type LogLevel = "log" | "warn" | "error"
interface ConsoleLog {
level: LogLevel
message: string
timestamp: Date
}
const props = withDefaults(defineProps<{
logs?: ConsoleLog[]
class?: HTMLAttributes["class"]
}>(), {
logs: () => [],
})
const context = useWebPreviewContext()
function levelClass(level: LogLevel) {
if (level === "error")
return "text-destructive-text"
if (level === "warn")
return "text-warning-text"
return "text-foreground"
}
</script>
<template>
<Collapsible
data-slot="ai-web-preview-console"
:class="cn('border-t border-border bg-card font-mono text-[11.5px] leading-[1.8]', props.class)"
:open="context.consoleOpen.value"
@update:open="context.setConsoleOpen"
>
<CollapsibleTrigger class="group/ai-console flex w-full items-center justify-between px-3 pt-2 pb-1 text-left font-mono text-[11px] font-medium tracking-[0.8px] text-muted-foreground uppercase outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring">
Console
<ChevronDownIcon class="size-3.5 transition-transform duration-200 group-data-[state=open]/ai-console:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent class="overflow-hidden">
<div class="max-h-48 overflow-y-auto px-3 pb-2">
<p v-if="!props.logs.length" class="text-muted-foreground">
No console output
</p>
<template v-else>
<div
v-for="(log, index) in props.logs"
:key="`${log.timestamp.getTime?.() ?? index}-${index}`"
:class="levelClass(log.level)"
>
<span class="text-muted-foreground">{{ log.timestamp.toLocaleTimeString([], { hour12: false }) }}</span>
{{ " " }}{{ log.level.padEnd(4, " ") }} {{ log.message }}
</div>
</template>
<slot />
</div>
</CollapsibleContent>
</Collapsible>
</template>
registry/components/ai/web-preview/WebPreviewNavigation.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-web-preview-navigation"
:class="cn('flex h-11 shrink-0 items-center gap-1 border-b border-border px-2', props.class)"
>
<slot />
</div>
</template>
registry/components/ai/web-preview/WebPreviewNavigationButton.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from "@/registry/edmi/ui/button"
import { Button } from "@/registry/edmi/ui/button"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/registry/edmi/ui/tooltip"
const props = withDefaults(defineProps<{
size?: ButtonVariants["size"]
variant?: ButtonVariants["variant"]
tooltip?: string
disabled?: boolean
class?: HTMLAttributes["class"]
}>(), {
tooltip: "",
disabled: false,
size: "icon-sm",
variant: "ghost",
})
</script>
<template>
<TooltipProvider v-if="props.tooltip">
<Tooltip>
<TooltipTrigger as-child>
<Button
type="button"
:aria-label="props.tooltip"
:variant="props.variant"
:size="props.size"
:disabled="props.disabled"
:class="props.class"
>
<slot />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ props.tooltip }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
v-else
type="button"
:aria-label="props.tooltip || undefined"
:variant="props.variant"
:size="props.size"
:disabled="props.disabled"
:class="props.class"
>
<slot />
</Button>
</template>
registry/components/ai/web-preview/WebPreviewUrl.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { LockIcon } from "@lucide/vue"
import { ref, watch } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { Input } from "@/registry/edmi/ui/input"
import { useWebPreviewContext } from "./context"
const props = withDefaults(defineProps<{
placeholder?: string
class?: HTMLAttributes["class"]
}>(), {
placeholder: "Enter URL...",
})
const context = useWebPreviewContext()
const inputValue = ref(context.url.value)
watch(
() => context.url.value,
(value) => {
inputValue.value = value
},
{ immediate: true },
)
function handleKeydown() {
context.setUrl(String(inputValue.value))
}
</script>
<template>
<div class="relative mx-1.5 flex-1">
<LockIcon class="pointer-events-none absolute top-1/2 left-2.5 size-3 -translate-y-1/2 text-muted-foreground" />
<Input
v-model="inputValue"
data-slot="ai-web-preview-url"
:class="cn('h-[30px] pl-7 font-mono text-xs', props.class)"
:placeholder="props.placeholder"
@keydown.enter="handleKeydown"
/>
</div>
</template>
registry/components/ai/web-preview/context.ts
import type { InjectionKey, Ref } from "vue";
import { inject, provide } from "vue";
export interface WebPreviewContextValue {
url: Ref<string>;
setUrl: (url: string) => void;
consoleOpen: Ref<boolean>;
setConsoleOpen: (open: boolean) => void;
}
const WebPreviewContextKey: InjectionKey<WebPreviewContextValue> =
Symbol("WebPreviewContext");
export function provideWebPreviewContext(value: WebPreviewContextValue) {
provide(WebPreviewContextKey, value);
}
export function useWebPreviewContext() {
const context = inject(WebPreviewContextKey, null);
if (!context) {
throw new Error("WebPreview components must be used within WebPreview");
}
return context;
}
registry/components/ai/web-preview/index.ts
export { default as WebPreview } from "./WebPreview.vue";
export { default as WebPreviewBody } from "./WebPreviewBody.vue";
export { default as WebPreviewConsole } from "./WebPreviewConsole.vue";
export { default as WebPreviewNavigation } from "./WebPreviewNavigation.vue";
export { default as WebPreviewNavigationButton } from "./WebPreviewNavigationButton.vue";
export { default as WebPreviewUrl } from "./WebPreviewUrl.vue";
src/lib/registry/ai/web-preview/index.ts
import WebPreview from "./web-preview.svelte";
import WebPreviewBody from "./web-preview-body.svelte";
import WebPreviewConsole from "./web-preview-console.svelte";
import WebPreviewNavigation from "./web-preview-navigation.svelte";
import WebPreviewNavigationButton from "./web-preview-navigation-button.svelte";
import WebPreviewUrl from "./web-preview-url.svelte";
export {
WebPreview,
WebPreviewBody,
WebPreviewConsole,
WebPreviewNavigation,
WebPreviewNavigationButton,
WebPreviewUrl,
};
src/lib/registry/ai/web-preview/use-web-preview.svelte.ts
import { getContext, setContext } from "svelte";
export interface WebPreviewContext {
url: string;
consoleOpen: boolean;
}
const KEY = Symbol("ai-web-preview");
export function setWebPreviewContext(ctx: WebPreviewContext) {
setContext(KEY, ctx);
}
export function useWebPreviewContext(): WebPreviewContext {
const ctx = getContext<WebPreviewContext | undefined>(KEY);
if (!ctx)
throw new Error("WebPreview components must be used within WebPreview");
return ctx;
}
src/lib/registry/ai/web-preview/web-preview-body.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLIframeAttributes } from "svelte/elements";
import { useWebPreviewContext } from "./use-web-preview.svelte.js";
let {
class: className,
src,
loading,
...restProps
}: HTMLIframeAttributes & {
/** Rendered over the frame, e.g. a spinner while the preview is generated. */
loading?: Snippet;
} = $props();
const preview = useWebPreviewContext();
const frameSrc = $derived((src ?? preview.url) || undefined);
</script>
<div data-slot="ai-web-preview-body" class="relative min-h-0 flex-1 bg-background">
<iframe
class={cn("size-full", className)}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation"
src={frameSrc}
title="Preview"
{...restProps}
></iframe>
{@render loading?.()}
</div>
src/lib/registry/ai/web-preview/web-preview-console.svelte
<script lang="ts">
import CaretDownIcon from "phosphor-svelte/lib/CaretDown";
import * as Collapsible from "$lib/registry/ui/collapsible/index.js";
import { cn } from "$lib/utils.js";
import type { Snippet } from "svelte";
import { useWebPreviewContext } from "./use-web-preview.svelte.js";
type LogLevel = "log" | "warn" | "error";
let {
logs = [],
class: className,
children,
}: {
logs?: { level: LogLevel; message: string; timestamp: Date }[];
class?: string;
children?: Snippet;
} = $props();
const preview = useWebPreviewContext();
function levelClass(level: LogLevel) {
if (level === "error") return "text-destructive-text";
if (level === "warn") return "text-warning-text";
return "text-foreground";
}
</script>
<Collapsible.Root
open={preview.consoleOpen}
onOpenChange={(value) => (preview.consoleOpen = value)}
data-slot="ai-web-preview-console"
class={cn("border-t border-border bg-card font-mono text-[11.5px] leading-[1.8]", className)}
>
<Collapsible.Trigger
class="group/ai-console flex w-full items-center justify-between px-3 pt-2 pb-1 text-left font-mono text-[11px] font-medium tracking-[0.8px] text-muted-foreground uppercase outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring"
>
Console
<CaretDownIcon
class="size-3.5 transition-transform duration-200 group-data-[state=open]/ai-console:rotate-180"
/>
</Collapsible.Trigger>
<Collapsible.Content class="overflow-hidden">
<div class="max-h-48 overflow-y-auto px-3 pb-2">
{#if logs.length === 0}
<p class="text-muted-foreground">No console output</p>
{:else}
{#each logs as log, index (`${log.timestamp.getTime()}-${index}`)}
<div class={levelClass(log.level)}>
<span class="text-muted-foreground"
>{log.timestamp.toLocaleTimeString([], { hour12: false })}</span
>
{log.level.padEnd(4, " ")}
{log.message}
</div>
{/each}
{/if}
{@render children?.()}
</div>
</Collapsible.Content>
</Collapsible.Root>
src/lib/registry/ai/web-preview/web-preview-navigation-button.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 {
tooltip,
variant = "ghost",
size = "icon-sm",
children,
...restProps
}: Omit<ButtonProps, "href"> & { tooltip?: string } = $props();
</script>
{#if tooltip}
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<Button {...props} aria-label={tooltip} {...restProps} {size} type="button" {variant}>
{@render children?.()}
</Button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{tooltip}</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
{:else}
<Button {...restProps} {size} type="button" {variant}>
{@render children?.()}
</Button>
{/if}
src/lib/registry/ai/web-preview/web-preview-navigation.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>> = $props();
</script>
<div
bind:this={ref}
data-slot="ai-web-preview-navigation"
class={cn("flex h-11 shrink-0 items-center gap-1 border-b border-border px-2", className)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ai/web-preview/web-preview-url.svelte
<script lang="ts">
import LockKeyIcon from "phosphor-svelte/lib/LockKey";
import { Input } from "$lib/registry/ui/input/index.js";
import { cn } from "$lib/utils.js";
import type { HTMLInputAttributes } from "svelte/elements";
import { useWebPreviewContext } from "./use-web-preview.svelte.js";
let {
class: className,
placeholder = "Enter URL...",
...restProps
}: Omit<HTMLInputAttributes, "value" | "type"> = $props();
const preview = useWebPreviewContext();
let inputValue = $state(preview.url);
$effect(() => {
inputValue = preview.url;
});
function onkeydown(event: KeyboardEvent) {
if (event.key === "Enter") preview.url = inputValue;
}
</script>
<div class="relative mx-1.5 flex-1">
<LockKeyIcon
class="pointer-events-none absolute top-1/2 left-2.5 size-3 -translate-y-1/2 text-muted-foreground"
/>
<Input
data-slot="ai-web-preview-url"
class={cn("h-[30px] pl-7 font-mono text-xs", className)}
{placeholder}
bind:value={inputValue}
{onkeydown}
{...(restProps as Record<string, unknown>)}
/>
</div>
src/lib/registry/ai/web-preview/web-preview.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { setWebPreviewContext } from "./use-web-preview.svelte.js";
let {
ref = $bindable(null),
url = $bindable(""),
consoleOpen = $bindable(false),
onUrlChange,
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
/** Current URL (`bind:url`); the initial value is the default URL. */
url?: string;
/** ✦ Console state (`bind:consoleOpen`); start `true` to open it. */
consoleOpen?: boolean;
onUrlChange?: (url: string) => void;
} = $props();
setWebPreviewContext({
get url() {
return url;
},
set url(value: string) {
url = value;
onUrlChange?.(value);
},
get consoleOpen() {
return consoleOpen;
},
set consoleOpen(value: boolean) {
consoleOpen = value;
},
});
</script>
<div
bind:this={ref}
data-slot="ai-web-preview"
class={cn("flex size-full flex-col overflow-hidden rounded-xl border border-border bg-card", className)}
{...restProps}
>
{@render children?.()}
</div>
Part Prop Type Default
WebPreview defaultUrl, onUrlChange, defaultConsoleOpen string, (url) => void, boolean "", false
WebPreviewNavigationButton tooltip string
WebPreviewBody loading ReactNode
WebPreviewConsole logs { level, message, timestamp }[] []
WebPreviewNavigation, Url
  • The URL field is mono with a lock icon; navigation buttons are ghost icon buttons (open in new tab and fullscreen are yours to add as buttons).
  • defaultConsoleOpen starts the console expanded.
  • The body is a sandboxed iframe; pass loading to cover it while the preview is generated.