Skip to content
edmi-ui

Transcription

Time-synced transcript: the active segment is highlighted, past is muted, future is dimmed, click a segment to seek.

StackBlitz ↗StackBlitz ↗StackBlitz ↗

Takes the segments of the AI SDK transcribe() result.

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

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

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

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

import {
Transcription,
TranscriptionSegment,
} from "@/components/ai/transcription";
<div className="flex w-full max-w-xl flex-col gap-3">
<button
className="self-start rounded-md border border-border px-3 py-1 text-sm hover:bg-accent"
type="button"
>
·{" "}
<span className="font-mono">s</span>
</button>
<Transcription>
{(segment, index) => (
<TranscriptionSegment index={index} key={index} segment={segment} />
)}
</Transcription>
</div>
<script setup lang="ts">
import {
Transcription,
TranscriptionSegment,
} from "@/components/ai/transcription";
</script>
<template>
<div class="flex w-full max-w-xl flex-col gap-3">
<button
type="button"
class="self-start rounded-md border border-border px-3 py-1 text-sm hover:bg-accent"
>
· <span class="font-mono">s</span>
</button>
<Transcription>
<template #default="{ segment, index }">
<TranscriptionSegment :segment="segment" :index="index" />
</template>
</Transcription>
</div>
</template>
<script lang="ts">
import {
Transcription,
TranscriptionSegment,
} from "$lib/components/ai/transcription";
</script>
<div class="flex w-full max-w-xl flex-col gap-3">
<button
type="button"
class="self-start rounded-md border border-border px-3 py-1 text-sm hover:bg-accent"
>
· <span class="font-mono">s</span>
</button>
<Transcription onSeek={() => {}}>
{#snippet children(segment, index)}
<TranscriptionSegment {segment} {index} />
{/snippet}
</Transcription>
</div>
registry/components/ai/transcription.tsx
"use client";
import type { Experimental_TranscriptionResult as TranscriptionResult } from "ai";
import { cn } from "cn";
import type { ComponentProps, ReactNode } from "react";
import { createContext, useCallback, useContext, useMemo } from "react";
import { useControllableState } from "@/registry/edmi/hooks/ai/use-controllable-state";
type TranscriptionSegment = TranscriptionResult["segments"][number];
interface TranscriptionContextValue {
segments: TranscriptionSegment[];
currentTime: number;
onTimeUpdate: (time: number) => void;
onSeek?: (time: number) => void;
}
const TranscriptionContext = createContext<TranscriptionContextValue | null>(
null,
);
const useTranscription = () => {
const context = useContext(TranscriptionContext);
if (!context) {
throw new Error(
"Transcription components must be used within Transcription",
);
}
return context;
};
export type TranscriptionProps = Omit<ComponentProps<"div">, "children"> & {
segments: TranscriptionSegment[];
currentTime?: number;
onSeek?: (time: number) => void;
children: (segment: TranscriptionSegment, index: number) => ReactNode;
};
export const Transcription = ({
segments,
currentTime: externalCurrentTime,
onSeek,
className,
children,
...props
}: TranscriptionProps) => {
const [currentTime, setCurrentTime] = useControllableState({
defaultProp: 0,
onChange: onSeek,
prop: externalCurrentTime,
});
const contextValue = useMemo(
() => ({ currentTime, onSeek, onTimeUpdate: setCurrentTime, segments }),
[currentTime, onSeek, setCurrentTime, segments],
);
return (
<TranscriptionContext.Provider value={contextValue}>
<div
className={cn(
"flex flex-wrap gap-x-1 gap-y-0.5 text-[15px] leading-[1.9]",
className,
)}
data-slot="ai-transcription"
{...props}
>
{segments
.filter((segment) => segment.text.trim())
.map((segment, index) => children(segment, index))}
</div>
</TranscriptionContext.Provider>
);
};
export type TranscriptionSegmentProps = ComponentProps<"button"> & {
segment: TranscriptionSegment;
index: number;
};
/** States: past (`--muted-foreground`), active (`--primary` fill), future (`--muted-foreground-2`). */
export const TranscriptionSegment = ({
segment,
index,
className,
onClick,
...props
}: TranscriptionSegmentProps) => {
const { currentTime, onSeek } = useTranscription();
const isActive =
currentTime >= segment.startSecond && currentTime < segment.endSecond;
const isPast = currentTime >= segment.endSecond;
const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
if (onSeek) {
onSeek(segment.startSecond);
}
onClick?.(event);
},
[onSeek, segment.startSecond, onClick],
);
return (
<button
className={cn(
"inline rounded px-[3px] py-0.5 text-left transition-colors",
isActive && "bg-primary text-primary-foreground",
isPast && "text-muted-foreground",
!(isActive || isPast) && "text-muted-foreground-2",
onSeek && !isActive && "cursor-pointer hover:text-foreground",
!onSeek && "cursor-default",
className,
)}
data-active={isActive}
data-index={index}
data-slot="ai-transcription-segment"
onClick={handleClick}
type="button"
{...props}
>
{segment.text}
</button>
);
};
registry/components/ai/transcription/Transcription.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { TranscriptionSegment } from "./context"
import { useVModel } from "@vueuse/core"
import { computed, getCurrentInstance, provide } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { TranscriptionKey } from "./context"
const props = withDefaults(defineProps<{
segments: TranscriptionSegment[]
currentTime?: number
class?: HTMLAttributes["class"]
}>(), { currentTime: undefined })
const emit = defineEmits<{
(e: "update:currentTime", time: number): void
(e: "seek", time: number): void
}>()
const model = useVModel(props, "currentTime", emit, { defaultValue: 0, passive: true })
const currentTime = computed({
get: () => model.value ?? 0,
set: (time: number) => { model.value = time },
})
// Segments are clickable only when somebody listens for `seek` or `update:currentTime`.
const vnodeProps = getCurrentInstance()?.vnode.props ?? {}
const seekable = "onSeek" in vnodeProps || "onUpdate:currentTime" in vnodeProps
provide(TranscriptionKey, {
segments: computed(() => props.segments),
currentTime,
onSeek: seekable
? (time: number) => {
currentTime.value = time
emit("seek", time)
}
: undefined,
})
</script>
<template>
<div
data-slot="ai-transcription"
:class="cn('flex flex-wrap gap-x-1 gap-y-0.5 text-[15px] leading-[1.9]', props.class)"
>
<template v-for="(segment, index) in props.segments" :key="`${segment.startSecond}-${segment.endSecond}`">
<slot v-if="segment.text.trim()" :segment="segment" :index="index" />
</template>
</div>
</template>
registry/components/ai/transcription/TranscriptionSegment.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { TranscriptionSegment } from "./context"
import { computed } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { useTranscriptionContext } from "./context"
// States: past (--muted-foreground), active (--primary fill), future (--muted-foreground-2).
const props = defineProps<{
segment: TranscriptionSegment
index: number
class?: HTMLAttributes["class"]
}>()
const { currentTime, onSeek } = useTranscriptionContext()
const isActive = computed(() => currentTime.value >= props.segment.startSecond && currentTime.value < props.segment.endSecond)
const isPast = computed(() => currentTime.value >= props.segment.endSecond)
function handleClick() {
onSeek?.(props.segment.startSecond)
}
</script>
<template>
<button
type="button"
data-slot="ai-transcription-segment"
:data-active="isActive"
:data-index="index"
:class="cn(
'inline rounded px-[3px] py-0.5 text-left transition-colors',
isActive && 'bg-primary text-primary-foreground',
isPast && 'text-muted-foreground',
!(isActive || isPast) && 'text-muted-foreground-2',
onSeek && !isActive && 'cursor-pointer hover:text-foreground',
!onSeek && 'cursor-default',
props.class,
)"
@click="handleClick"
>
{{ segment.text }}
</button>
</template>
registry/components/ai/transcription/context.ts
import type { Experimental_TranscriptionResult as TranscriptionResult } from "ai";
import type { ComputedRef, InjectionKey, Ref } from "vue";
import { inject } from "vue";
export type TranscriptionSegment = NonNullable<
TranscriptionResult["segments"]
>[number];
export interface TranscriptionContextValue {
segments: ComputedRef<TranscriptionSegment[]>;
currentTime: Ref<number>;
/** Present only when the consumer listens for `seek`; segments are clickable then. */
onSeek?: (time: number) => void;
}
export const TranscriptionKey: InjectionKey<TranscriptionContextValue> =
Symbol("Transcription");
export function useTranscriptionContext(): TranscriptionContextValue {
const context = inject(TranscriptionKey);
if (!context) {
throw new Error(
"Transcription components must be used within Transcription",
);
}
return context;
}
registry/components/ai/transcription/index.ts
export * from "./context";
export { default as Transcription } from "./Transcription.vue";
export { default as TranscriptionSegment } from "./TranscriptionSegment.vue";
src/lib/registry/ai/transcription/transcription-segment.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { HTMLButtonAttributes } from "svelte/elements";
import { type TranscriptionSegment, useTranscription } from "./use-transcription.svelte.js";
// States: past (--muted-foreground), active (--primary fill), future (--muted-foreground-2).
let {
segment,
index,
class: className,
onclick,
...restProps
}: Omit<HTMLButtonAttributes, "children"> & {
segment: TranscriptionSegment;
index: number;
} = $props();
const ctx = useTranscription();
const isActive = $derived(
ctx.currentTime >= segment.startSecond && ctx.currentTime < segment.endSecond
);
const isPast = $derived(ctx.currentTime >= segment.endSecond);
</script>
<button
type="button"
data-slot="ai-transcription-segment"
data-active={isActive}
data-index={index}
class={cn(
"inline rounded px-[3px] py-0.5 text-left transition-colors",
isActive && "bg-primary text-primary-foreground",
isPast && "text-muted-foreground",
!(isActive || isPast) && "text-muted-foreground-2",
ctx.onSeek && !isActive && "cursor-pointer hover:text-foreground",
!ctx.onSeek && "cursor-default",
className
)}
onclick={(event) => {
ctx.onSeek?.(segment.startSecond);
onclick?.(event);
}}
{...restProps}
>
{segment.text}
</button>
src/lib/registry/ai/transcription/transcription.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
import { setTranscriptionContext, type TranscriptionSegment } from "./use-transcription.svelte.js";
let {
segments,
currentTime = $bindable(0),
onSeek,
class: className,
children,
...restProps
}: Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
segments: TranscriptionSegment[];
/** Seconds; `bind:currentTime` to follow an audio element. */
currentTime?: number;
onSeek?: (time: number) => void;
/** Receives each segment and its index: `{#snippet children(segment, index)}`. */
children: Snippet<[TranscriptionSegment, number]>;
} = $props();
setTranscriptionContext({
get segments() {
return segments;
},
get currentTime() {
return currentTime;
},
get onSeek() {
return onSeek
? (time: number) => {
currentTime = time;
onSeek(time);
}
: undefined;
},
});
</script>
<div
data-slot="ai-transcription"
class={cn("flex flex-wrap gap-x-1 gap-y-0.5 text-[15px] leading-[1.9]", className)}
{...restProps}
>
{#each segments as segment, index (`${segment.startSecond}-${segment.endSecond}-${index}`)}
{#if segment.text.trim()}
{@render children(segment, index)}
{/if}
{/each}
</div>
src/lib/registry/ai/transcription/use-transcription.svelte.ts
import type { Experimental_TranscriptionResult as TranscriptionResult } from "ai";
import { getContext, setContext } from "svelte";
export type TranscriptionSegment = NonNullable<
TranscriptionResult["segments"]
>[number];
/** Getter object so consumers stay reactive when the props change. */
export interface TranscriptionContext {
readonly segments: TranscriptionSegment[];
readonly currentTime: number;
/** Present only when the consumer passes `onSeek`; segments are clickable then. */
readonly onSeek: ((time: number) => void) | undefined;
}
const KEY = Symbol("ai-transcription");
export function setTranscriptionContext(value: TranscriptionContext) {
return setContext(KEY, value);
}
export function useTranscription(): TranscriptionContext {
const context = getContext<TranscriptionContext | undefined>(KEY);
if (!context) {
throw new Error(
"Transcription components must be used within Transcription",
);
}
return context;
}
src/lib/registry/ai/transcription/index.ts
import Root from "./transcription.svelte";
import Segment from "./transcription-segment.svelte";
export type { TranscriptionSegment as TranscriptionSegmentData } from "./use-transcription.svelte.js";
export {
Root,
//
Root as Transcription,
Segment,
Segment as TranscriptionSegment,
};
Part Prop Type Default
Transcription segments AI SDK transcription segments
Transcription currentTime, onSeek number (seconds), (time) => void
Transcription children (segment, index) => ReactNode
TranscriptionSegment segment, index
  • States use tokens: active = --primary fill with --primary-foreground, past = --muted-foreground, future = --muted-foreground-2.
  • Vue: v-model:current-time, @seek, default slot { segment, index }; segments are clickable only when a listener is attached. Svelte: bind:currentTime, onSeek, children snippet (segment, index).