# Video

> Video playback with controls and poster.

A shadcn/ui block from the [@tool-ui](https://www.tool-ui.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/tool-ui/video).

- Page, with a live preview: https://shadcn.exchange/r/tool-ui/video
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/tool-ui/video-light-3c631eb83b66eab8.png
- Registry item: https://www.tool-ui.com/r/video.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://www.tool-ui.com/r/video.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `zod`
- Registry items: `button`

## Files

### `components/tool-ui/shared/contract.ts`

```ts
import { z } from "zod";
import { parseWithSchema, safeParseWithSchema } from "./parse";

export interface ToolUiContract<T> {
  schema: z.ZodType<T>;
  parse: (input: unknown) => T;
  safeParse: (input: unknown) => T | null;
}

export function defineToolUiContract<T>(
  componentName: string,
  schema: z.ZodType<T>,
): ToolUiContract<T> {
  return {
    schema,
    parse: (input: unknown) => parseWithSchema(schema, input, componentName),
    safeParse: (input: unknown) => safeParseWithSchema(schema, input),
  };
}
```

### `components/tool-ui/shared/media/aspect-ratio.ts`

```ts
import { z } from "zod";

export const AspectRatioSchema = z
  .enum(["auto", "1:1", "4:3", "16:9", "9:16"])
  .default("auto");

export type AspectRatio = z.infer<typeof AspectRatioSchema>;

export const MediaFitSchema = z.enum(["cover", "contain"]).default("cover");

export type MediaFit = z.infer<typeof MediaFitSchema>;

export const RATIO_CLASS_MAP: Record<AspectRatio, string> = {
  auto: "",
  "1:1": "aspect-square",
  "4:3": "aspect-[4/3]",
  "16:9": "aspect-video",
  "9:16": "aspect-[9/16]",
};

export function getRatioClass(ratio: AspectRatio): string {
  return RATIO_CLASS_MAP[ratio];
}

export function getFitClass(fit: MediaFit): string {
  return fit === "cover" ? "object-cover" : "object-contain";
}
```

### `components/tool-ui/shared/media/format-utils.ts`

```ts
/**
 * Format duration in milliseconds to human-readable string.
 * @example formatDuration(128000) => "2:08"
 * @example formatDuration(3661000) => "1:01:01"
 */
export function formatDuration(durationMs: number): string {
  const totalSeconds = Math.round(durationMs / 1000);
  const hours = Math.floor(totalSeconds / 3600);
  const minutes = Math.floor((totalSeconds % 3600) / 60);
  const seconds = totalSeconds % 60;

  if (hours > 0) {
    return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds
      .toString()
      .padStart(2, "0")}`;
  }
  return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}

/**
 * Format file size in bytes to human-readable string.
 * @example formatFileSize(1024) => "1 KB"
 * @example formatFileSize(1536000) => "1.5 MB"
 */
export function formatFileSize(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`;
  const units = ["KB", "MB", "GB"];
  let size = bytes / 1024;
  let unit = 0;
  while (size >= 1024 && unit < units.length - 1) {
    size /= 1024;
    unit += 1;
  }
  return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unit]}`;
}
```

### `components/tool-ui/shared/media/index.ts`

```ts
export {
  AspectRatioSchema,
  MediaFitSchema,
  RATIO_CLASS_MAP,
  getRatioClass,
  getFitClass,
  type AspectRatio,
  type MediaFit,
} from "./aspect-ratio";

export { OVERLAY_GRADIENT } from "./overlay-gradient";

export { formatDuration, formatFileSize } from "./format-utils";

export { sanitizeHref } from "./sanitize-href";
export {
  resolveSafeNavigationHref,
  openSafeNavigationHref,
} from "./safe-navigation";
```

### `components/tool-ui/shared/media/overlay-gradient.ts`

```ts
/**
 * Eased gradient for hover overlays on media elements.
 * Creates a smooth fade from opaque black at top to transparent.
 *
 * @see https://larsenwork.com/easing-gradients/
 */
export const OVERLAY_GRADIENT = `linear-gradient(
  to bottom,
  hsl(0, 0%, 0%) 0%,
  hsla(0, 0%, 0%, 0.987) 8.3%,
  hsla(0, 0%, 0%, 0.951) 16.6%,
  hsla(0, 0%, 0%, 0.896) 24.6%,
  hsla(0, 0%, 0%, 0.825) 32.5%,
  hsla(0, 0%, 0%, 0.741) 40.1%,
  hsla(0, 0%, 0%, 0.648) 47.6%,
  hsla(0, 0%, 0%, 0.55) 54.8%,
  hsla(0, 0%, 0%, 0.45) 61.7%,
  hsla(0, 0%, 0%, 0.352) 68.3%,
  hsla(0, 0%, 0%, 0.259) 74.5%,
  hsla(0, 0%, 0%, 0.175) 80.4%,
  hsla(0, 0%, 0%, 0.104) 86%,
  hsla(0, 0%, 0%, 0.049) 91.1%,
  hsla(0, 0%, 0%, 0.013) 95.8%,
  hsla(0, 0%, 0%, 0) 100%
)` as const;
```

### `components/tool-ui/shared/media/safe-navigation.ts`

```ts
import { sanitizeHref } from "./sanitize-href";

export function resolveSafeNavigationHref(
  ...candidates: Array<string | null | undefined>
): string | undefined {
  for (const candidate of candidates) {
    const safeHref = sanitizeHref(candidate ?? undefined);
    if (safeHref) {
      return safeHref;
    }
  }

  return undefined;
}

export function openSafeNavigationHref(href: string | undefined): boolean {
  if (!href || typeof window === "undefined") {
    return false;
  }

  window.open(href, "_blank", "noopener,noreferrer");
  return true;
}
```

### `components/tool-ui/shared/media/sanitize-href.ts`

```ts
/**
 * Sanitize a URL to ensure it's safe for use in href attributes.
 * Allows:
 * - Absolute http(s) URLs
 * - Relative URLs (/path, ./path, ../path, ?query, #hash)
 *
 * @returns The sanitized URL string, or undefined if invalid/unsafe
 */
export function sanitizeHref(href?: string): string | undefined {
  if (!href) return undefined;
  const candidate = href.trim();
  if (!candidate) return undefined;

  if (
    candidate.startsWith("/") ||
    candidate.startsWith("./") ||
    candidate.startsWith("../") ||
    candidate.startsWith("?") ||
    candidate.startsWith("#")
  ) {
    if (candidate.startsWith("//")) return undefined;
    // eslint-disable-next-line no-control-regex -- intentionally matching control characters
    if (/[\u0000-\u001F\u007F]/.test(candidate)) return undefined;
    return candidate;
  }

  try {
    const url = new URL(candidate);
    if (url.protocol === "http:" || url.protocol === "https:") {
      return url.toString();
    }
  } catch {
    return undefined;
  }
  return undefined;
}
```

### `components/tool-ui/shared/parse.ts`

```ts
import { z } from "zod";

function formatZodPath(path: Array<string | number | symbol>): string {
  if (path.length === 0) return "root";
  return path
    .map((segment) =>
      typeof segment === "number" ? `[${segment}]` : String(segment),
    )
    .join(".");
}

/**
 * Format Zod errors into a compact `path: message` string.
 */
export function formatZodError(error: z.ZodError): string {
  const parts = error.issues.map((issue) => {
    const path = formatZodPath(issue.path);
    return `${path}: ${issue.message}`;
  });

  return Array.from(new Set(parts)).join("; ");
}

/**
 * Parse unknown input and throw a readable error.
 */
export function parseWithSchema<T>(
  schema: z.ZodType<T>,
  input: unknown,
  name: string,
): T {
  const res = schema.safeParse(input);
  if (!res.success) {
    throw new Error(`Invalid ${name} payload: ${formatZodError(res.error)}`);
  }
  return res.data;
}

/**
 * Parse unknown input, returning `null` instead of throwing on failure.
 *
 * Use this in assistant-ui `render` functions where `args` stream in
 * incrementally and may be incomplete until the tool call finishes.
 */
export function safeParseWithSchema<T>(
  schema: z.ZodType<T>,
  input: unknown,
): T | null {
  const res = schema.safeParse(input);
  return res.success ? res.data : null;
}
```

### `components/tool-ui/shared/schema.ts`

```ts
import { z } from "zod";
import type { ReactNode } from "react";

/**
 * Tool UI conventions:
 * - Serializable schemas are JSON-safe (no callbacks/ReactNode/`className`).
 * - Schema: `SerializableXSchema`
 * - Parser: `parseSerializableX(input: unknown)` (throws on invalid)
 * - Safe parser: `safeParseSerializableX(input: unknown)` (returns `null` on invalid)
 * - Actions: `LocalActions` for non-receipt actions and `DecisionActions` for consequential actions
 * - Root attrs: `data-tool-ui-id` + `data-slot`
 */

/**
 * Schema for tool UI identity.
 *
 * Every tool UI should have a unique identifier that:
 * - Is stable across re-renders
 * - Is meaningful (not auto-generated)
 * - Is unique within the conversation
 *
 * Format recommendation: `{component-type}-{semantic-identifier}`
 * Examples: "data-table-expenses-q3", "option-list-deploy-target"
 */
export const ToolUIIdSchema = z.string().min(1);

export type ToolUIId = z.infer<typeof ToolUIIdSchema>;

/**
 * Primary role of a Tool UI surface in a chat context.
 */
export const ToolUIRoleSchema = z.enum([
  "information",
  "decision",
  "control",
  "state",
  "composite",
]);

export type ToolUIRole = z.infer<typeof ToolUIRoleSchema>;

export const ToolUIReceiptOutcomeSchema = z.enum([
  "success",
  "partial",
  "failed",
  "cancelled",
]);

export type ToolUIReceiptOutcome = z.infer<typeof ToolUIReceiptOutcomeSchema>;

/**
 * Optional receipt metadata: a durable summary of an outcome.
 */
export const ToolUIReceiptSchema = z.object({
  outcome: ToolUIReceiptOutcomeSchema,
  summary: z.string().min(1),
  identifiers: z.record(z.string(), z.string()).optional(),
  at: z.string().datetime(),
});

export type ToolUIReceipt = z.infer<typeof ToolUIReceiptSchema>;

/**
 * Base schema for Tool UI payloads (id + optional role/receipt).
 */
export const ToolUISurfaceSchema = z.object({
  id: ToolUIIdSchema,
  role: ToolUIRoleSchema.optional(),
  receipt: ToolUIReceiptSchema.optional(),
});

export type ToolUISurface = z.infer<typeof ToolUISurfaceSchema>;

export const ActionSchema = z.object({
  id: z.string().min(1),
  label: z.string().min(1),
  /**
   * Canonical narration the assistant can use after this action is taken.
   *
   * Example: "I exported the table as CSV." / "I opened the link in a new tab."
   */
  sentence: z.string().optional(),
  confirmLabel: z.string().optional(),
  variant: z
    .enum(["default", "destructive", "secondary", "ghost", "outline"])
    .optional(),
  icon: z.custom<ReactNode>().optional(),
  loading: z.boolean().optional(),
  disabled: z.boolean().optional(),
  shortcut: z.string().optional(),
});

export type Action = z.infer<typeof ActionSchema>;
export type LocalAction = Action;
export type DecisionAction = Action;

export const DecisionResultSchema = z.object({
  kind: z.literal("decision"),
  version: z.literal(1),
  decisionId: z.string().min(1),
  actionId: z.string().min(1),
  actionLabel: z.string().min(1),
  at: z.string().datetime(),
  payload: z.record(z.string(), z.unknown()).optional(),
});

export type DecisionResult<
  TPayload extends Record<string, unknown> = Record<string, unknown>,
> = Omit<z.infer<typeof DecisionResultSchema>, "payload"> & {
  payload?: TPayload;
};

export function createDecisionResult<
  TPayload extends Record<string, unknown> = Record<string, unknown>,
>(args: {
  decisionId: string;
  action: { id: string; label: string };
  payload?: TPayload;
}): DecisionResult<TPayload> {
  return {
    kind: "decision",
    version: 1,
    decisionId: args.decisionId,
    actionId: args.action.id,
    actionLabel: args.action.label,
    at: new Date().toISOString(),
    payload: args.payload,
  };
}

export const ActionButtonsPropsSchema = z.object({
  actions: z.array(ActionSchema).min(1),
  align: z.enum(["left", "center", "right"]).optional(),
  confirmTimeout: z.number().positive().optional(),
  className: z.string().optional(),
});

export const SerializableActionSchema = ActionSchema.omit({ icon: true });
export const SerializableActionsSchema = ActionButtonsPropsSchema.extend({
  actions: z.array(SerializableActionSchema),
}).omit({ className: true });

export interface ActionsConfig {
  items: Action[];
  align?: "left" | "center" | "right";
  confirmTimeout?: number;
}

export const SerializableActionsConfigSchema = z.object({
  items: z.array(SerializableActionSchema).min(1),
  align: z.enum(["left", "center", "right"]).optional(),
  confirmTimeout: z.number().positive().optional(),
});

export type SerializableActionsConfig = z.infer<
  typeof SerializableActionsConfigSchema
>;

export type SerializableAction = z.infer<typeof SerializableActionSchema>;
```

### `components/tool-ui/video/_adapter.tsx`

```tsx
/**
 * Adapter: UI and utility re-exports for copy-standalone portability.
 */
"use client";

export { cn } from "@/lib/utils";
export { Button } from "@/components/ui/button";
```

### `components/tool-ui/video/context.tsx`

```tsx
"use client";

import * as React from "react";

export interface VideoPlaybackState {
  playing: boolean;
  muted: boolean;
}

export interface VideoContextValue {
  state: VideoPlaybackState;
  setState: (patch: Partial<VideoPlaybackState>) => void;
  videoElement: HTMLVideoElement | null;
  setVideoElement: (node: HTMLVideoElement | null) => void;
}

const VideoContext = React.createContext<VideoContextValue | null>(null);

export function useVideo() {
  const ctx = React.use(VideoContext);
  if (!ctx) {
    throw new Error("useVideo must be used within a <VideoProvider />");
  }
  return ctx;
}

export interface VideoProviderProps {
  children: React.ReactNode;
  defaultState?: Partial<VideoPlaybackState>;
}

export function VideoProvider({ children, defaultState }: VideoProviderProps) {
  const [state, setStateInternal] = React.useState<VideoPlaybackState>({
    playing: defaultState?.playing ?? false,
    muted: defaultState?.muted ?? true,
  });

  const [videoElement, setVideoElement] =
    React.useState<HTMLVideoElement | null>(null);

  const setState = React.useCallback((patch: Partial<VideoPlaybackState>) => {
    setStateInternal((prev) => ({ ...prev, ...patch }));
  }, []);

  const value = React.useMemo(
    () => ({ state, setState, videoElement, setVideoElement }),
    [state, setState, videoElement],
  );

  return (
    <VideoContext.Provider value={value}>{children}</VideoContext.Provider>
  );
}
```

### `components/tool-ui/video/index.ts`

```ts
export { Video } from "./video";
export type { VideoProps } from "./video";
export type { SerializableVideo, Source } from "./schema";
```

### `components/tool-ui/video/README.md`

```md
# Video

Implementation for the "video" Tool UI surface.

## Files

- public exports: components/tool-ui/video/index.ts
- serializable schema + parse helpers: components/tool-ui/video/schema.ts

## Companion assets

- Docs page: app/docs/video/content.mdx
- Preset payload: lib/presets/video.ts

## Quick check

Run this after edits:

pnpm test
```

### `components/tool-ui/video/schema.ts`

```ts
import { z } from "zod";
import { defineToolUiContract } from "../shared/contract";
import {
  ToolUIIdSchema,
  ToolUIReceiptSchema,
  ToolUIRoleSchema,
} from "../shared/schema";

import { AspectRatioSchema, MediaFitSchema } from "../shared/media";

export const SourceSchema = z.object({
  label: z.string(),
  iconUrl: z.url().optional(),
  url: z.url().optional(),
});

export type Source = z.infer<typeof SourceSchema>;

export const SerializableVideoSchema = z.object({
  id: ToolUIIdSchema,
  role: ToolUIRoleSchema.optional(),
  receipt: ToolUIReceiptSchema.optional(),
  assetId: z.string(),
  src: z.url(),
  poster: z.url().optional(),
  title: z.string().optional(),
  description: z.string().optional(),
  href: z.url().optional(),
  domain: z.string().optional(),
  durationMs: z.number().int().positive().optional(),
  ratio: AspectRatioSchema.optional(),
  fit: MediaFitSchema.optional(),
  createdAt: z.string().datetime().optional(),
  locale: z.string().optional(),
  source: SourceSchema.optional(),
});

export type SerializableVideo = z.infer<typeof SerializableVideoSchema>;

const SerializableVideoSchemaContract = defineToolUiContract(
  "Video",
  SerializableVideoSchema,
);

export const parseSerializableVideo: (input: unknown) => SerializableVideo =
  SerializableVideoSchemaContract.parse;

export const safeParseSerializableVideo: (
  input: unknown,
) => SerializableVideo | null = SerializableVideoSchemaContract.safeParse;
```

### `components/tool-ui/video/video-helpers.ts`

```ts
import type { SerializableVideo } from "./schema";
import { resolveSafeNavigationHref, sanitizeHref } from "../shared/media";

export type VideoMediaEvent = "mute" | "unmute";

export interface ResolvedVideoNavigation {
  sanitizedHref: string | undefined;
  sanitizedSourceUrl: string | undefined;
  primaryHref: string | undefined;
}

export function getMuteMediaEvent(
  previousMuted: boolean,
  nextMuted: boolean,
): VideoMediaEvent | null {
  if (previousMuted === nextMuted) {
    return null;
  }

  return nextMuted ? "mute" : "unmute";
}

export function resolveVideoNavigation(
  rawHref: string | undefined,
  rawSourceUrl: string | undefined,
): ResolvedVideoNavigation {
  const sanitizedHref = sanitizeHref(rawHref);
  const sanitizedSourceUrl = sanitizeHref(rawSourceUrl);

  return {
    sanitizedHref,
    sanitizedSourceUrl,
    primaryHref: resolveSafeNavigationHref(sanitizedHref, sanitizedSourceUrl),
  };
}

export function normalizeVideoDataForCallback(
  video: SerializableVideo,
  normalized: {
    ratio: NonNullable<SerializableVideo["ratio"]>;
    fit: NonNullable<SerializableVideo["fit"]>;
    locale: string;
    sanitizedHref: string | undefined;
    sanitizedSourceUrl: string | undefined;
  },
): SerializableVideo {
  return {
    ...video,
    ratio: normalized.ratio,
    fit: normalized.fit,
    href: normalized.sanitizedHref,
    source: video.source
      ? { ...video.source, url: normalized.sanitizedSourceUrl }
      : undefined,
    locale: normalized.locale,
  };
}
```

### `components/tool-ui/video/video.tsx`

```tsx
"use client";

import * as React from "react";
import { ExternalLink, Play } from "lucide-react";
import { cn, Button } from "./_adapter";

import {
  formatDuration,
  getFitClass,
  openSafeNavigationHref,
  OVERLAY_GRADIENT,
  RATIO_CLASS_MAP,
} from "../shared/media";
import { VideoProvider, useVideo } from "./context";
import type { SerializableVideo } from "./schema";
import {
  getMuteMediaEvent,
  normalizeVideoDataForCallback,
  resolveVideoNavigation,
} from "./video-helpers";

const FALLBACK_LOCALE = "en-US";

export interface VideoProps extends SerializableVideo {
  className?: string;
  // Keep behavior flags intentionally minimal; prefer explicit variants over more booleans.
  autoPlay?: boolean;
  defaultMuted?: boolean;
  onNavigate?: (href: string, video: SerializableVideo) => void;
  onMediaEvent?: (type: "play" | "pause" | "mute" | "unmute") => void;
}

function VideoRoot(props: VideoProps) {
  const { defaultMuted = true, ...rest } = props;

  return (
    <VideoProvider defaultState={{ muted: defaultMuted }}>
      <VideoInner {...rest} />
    </VideoProvider>
  );
}

function VideoInner(props: Omit<VideoProps, "defaultMuted">) {
  const {
    className,
    autoPlay = true,
    onNavigate,
    onMediaEvent,
    ...serializable
  } = props;

  const {
    id,
    src,
    poster,
    title,
    description,
    href: rawHref,
    domain,
    durationMs,
    ratio = "16:9",
    fit = "cover",
    createdAt,
    source,
    locale: providedLocale,
  } = serializable;

  const locale = providedLocale ?? FALLBACK_LOCALE;
  const { sanitizedHref, sanitizedSourceUrl, primaryHref } =
    resolveVideoNavigation(rawHref, source?.url);

  const videoData: SerializableVideo = normalizeVideoDataForCallback(
    serializable,
    {
      ratio,
      fit,
      locale,
      sanitizedHref,
      sanitizedSourceUrl,
    },
  );

  const { state, setState, setVideoElement } = useVideo();
  const videoRef = React.useRef<HTMLVideoElement | null>(null);
  const previousMutedRef = React.useRef(state.muted);

  React.useEffect(() => {
    setVideoElement(videoRef.current);
    return () => setVideoElement(null);
  }, [setVideoElement]);

  React.useEffect(() => {
    const video = videoRef.current;
    if (!video) return;
    if (video.muted !== state.muted) {
      video.muted = state.muted;
    }
  }, [state.muted]);

  React.useEffect(() => {
    const video = videoRef.current;
    if (!video) return;
    if (state.playing && video.paused) {
      void video.play().catch(() => undefined);
    } else if (!state.playing && !video.paused) {
      video.pause();
    }
  }, [state.playing]);

  const navigate = (targetHref: string) => {
    if (onNavigate) {
      onNavigate(targetHref, videoData);
    } else {
      openSafeNavigationHref(targetHref);
    }
  };

  const handleWatch = (event: React.MouseEvent<HTMLButtonElement>) => {
    event.preventDefault();
    event.stopPropagation();
    const video = videoRef.current;
    if (!video) return;
    if (video.paused) {
      void video.play().catch(() => undefined);
    } else {
      video.pause();
    }
  };

  const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
    event.preventDefault();
    event.stopPropagation();
    if (!primaryHref) return;
    navigate(primaryHref);
  };

  const sourceLabel = source?.label;
  const metadataDomain = domain && domain !== sourceLabel ? domain : undefined;
  const hasMetadata = Boolean(
    description || sourceLabel || metadataDomain || durationMs || createdAt,
  );
  const hasOverlay = Boolean(title || primaryHref);

  return (
    <article
      className={cn("relative w-full min-w-80 max-w-md", className)}
      lang={locale}
      data-tool-ui-id={id}
      data-slot="video"
    >
      <div
        className={cn(
          "group @container relative isolate flex w-full min-w-0 flex-col overflow-hidden rounded-xl",
          "border border-border bg-card text-sm shadow-xs",
        )}
      >
        <div
          className={cn(
            "group relative w-full overflow-hidden bg-black",
            ratio !== "auto" ? RATIO_CLASS_MAP[ratio] : "aspect-video",
          )}
        >
          <video
            ref={videoRef}
            className={cn(
              "relative z-10 h-full w-full transition-transform duration-200 group-hover:scale-[1.01]",
              getFitClass(fit),
              ratio !== "auto" && "absolute inset-0 h-full w-full",
            )}
            src={src}
            poster={poster}
            controls
            playsInline
            autoPlay={autoPlay}
            preload="metadata"
            muted={state.muted}
            onPlay={() => {
              setState({ playing: true });
              onMediaEvent?.("play");
            }}
            onPause={() => {
              setState({ playing: false });
              onMediaEvent?.("pause");
            }}
            onVolumeChange={(event) => {
              const target = event.currentTarget;
              setState({ muted: target.muted });
              const mediaEvent = getMuteMediaEvent(
                previousMutedRef.current,
                target.muted,
              );
              previousMutedRef.current = target.muted;
              if (mediaEvent) {
                onMediaEvent?.(mediaEvent);
              }
            }}
          />
          {hasOverlay && (
            <>
              <div
                className="pointer-events-none absolute inset-x-0 top-0 z-20 h-32 opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100"
                style={{ backgroundImage: OVERLAY_GRADIENT }}
              />
              <div className="absolute inset-x-0 top-0 z-30 flex items-start justify-between gap-2 px-5 pt-4 opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100">
                {title ? (
                  <div className="line-clamp-2 max-w-[70%] font-semibold text-white drop-shadow-sm">
                    {title}
                  </div>
                ) : (
                  <span className="sr-only">Video controls</span>
                )}
                <div className="flex items-center gap-2">
                  {primaryHref && (
                    <Button
                      variant="secondary"
                      size="sm"
                      onClick={handleOpen}
                      className="bg-black/55 text-white hover:bg-black/70"
                    >
                      <ExternalLink
                        className="mr-1 h-4 w-4"
                        aria-hidden="true"
                      />
                      Open
                    </Button>
                  )}
                  <Button
                    variant="default"
                    size="sm"
                    onClick={handleWatch}
                    className="shadow-sm"
                  >
                    <Play className="mr-1 h-4 w-4" aria-hidden="true" />
                    Watch
                  </Button>
                </div>
              </div>
            </>
          )}
        </div>

        {hasMetadata && (
          <div className="flex flex-col gap-1.5 px-4 py-3">
            {description && (
              <p className="text-foreground line-clamp-2 text-sm leading-snug">
                {description}
              </p>
            )}
            <div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
              {sourceLabel && <span>{sourceLabel}</span>}
              {metadataDomain && <span>{metadataDomain}</span>}
              {typeof durationMs === "number" && (
                <span>{formatDuration(durationMs)}</span>
              )}
              {createdAt && (
                <time dateTime={createdAt}>
                  {formatCreatedAt(createdAt, locale)}
                </time>
              )}
            </div>
          </div>
        )}
      </div>
    </article>
  );
}

function formatCreatedAt(createdAt: string, locale: string): string {
  const date = new Date(createdAt);
  if (Number.isNaN(date.getTime())) {
    return createdAt;
  }

  return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(date);
}

type VideoComponent = typeof VideoRoot & {
  Root: typeof VideoRoot;
};

export const Video = Object.assign(VideoRoot, {
  Root: VideoRoot,
}) as VideoComponent;
```
