Vela UI Docsvenizia.ai

Doc Present

A fullscreen slideshow over the page, scaled to fit, with Space, arrow, Page Up/Down, Home, End, f and Escape keys.

npx shadcn@latest add @velaui/doc-present
npx shadcn@latest add @velaui/doc-present

Examples

  • Default

View code

Doc Present · Default

Installation

pnpm dlx velaui add @velaui/doc-present
pnpm dlx velaui add @velaui/doc-present

Code

doc-present-default.tsx
import * as React from "react";

import { DocPresent } from "@/components/ui/doc-present";

const SLIDES = [
  <h1 key="t" className="text-center text-4xl font-semibold">
    Q4 launch plan
  </h1>,
  <div key="g" className="space-y-3">
    <h2 className="text-xl font-semibold">Goals</h2>
    <ul className="list-disc space-y-1 pl-5 text-sm">
      <li>Ship the new onboarding</li>
      <li>Move billing to the new provider</li>
    </ul>
  </div>,
  <div key="s" className="space-y-3">
    <h2 className="text-xl font-semibold">Steps</h2>
    <ol className="list-decimal space-y-1 pl-5 text-sm">
      <li>Freeze the schema</li>
      <li>Run the dry migration</li>
      <li>Cut over</li>
    </ol>
  </div>,
];

export default function Example() {
  const [open, setOpen] = React.useState(false);
  return (
    <div className="w-full max-w-xl space-y-2 p-6">
      <button type="button" className="rounded-md border border-border-default px-3 py-1.5 text-sm" onClick={() => setOpen(true)}>
        Present
      </button>
      <p className="text-sm text-text-muted">Space, arrows and Page Up/Down move; Home and End jump; f is fullscreen; Escape closes.</p>
      {open && <DocPresent slides={SLIDES} onClose={() => setOpen(false)} />}
    </div>
  );
}

Usage

import * as React from "react";

import { DocPresent } from "@/components/ui/doc-present";

const SLIDES = [
  <h1 key="t" className="text-center text-4xl font-semibold">
    Q4 launch plan
  </h1>,
  <div key="g" className="space-y-3">
    <h2 className="text-xl font-semibold">Goals</h2>
    <ul className="list-disc space-y-1 pl-5 text-sm">
      <li>Ship the new onboarding</li>
      <li>Move billing to the new provider</li>
    </ul>
  </div>,
  <div key="s" className="space-y-3">
    <h2 className="text-xl font-semibold">Steps</h2>
    <ol className="list-decimal space-y-1 pl-5 text-sm">
      <li>Freeze the schema</li>
      <li>Run the dry migration</li>
      <li>Cut over</li>
    </ol>
  </div>,
];

export default function Example() {
  const [open, setOpen] = React.useState(false);
  return (
    <div className="w-full max-w-xl space-y-2 p-6">
      <button type="button" className="rounded-md border border-border-default px-3 py-1.5 text-sm" onClick={() => setOpen(true)}>
        Present
      </button>
      <p className="text-sm text-text-muted">Space, arrows and Page Up/Down move; Home and End jump; f is fullscreen; Escape closes.</p>
      {open && <DocPresent slides={SLIDES} onClose={() => setOpen(false)} />}
    </div>
  );
}

Props

PropTypeDefault
classNamestring—
onCloserequired() => void—
slidesrequiredReact.ReactNode[]—
startnumber0

Takes these and every prop of DocPresentProps.

Dependencies

npm
none besides React
Installs with it
nothing else

Source

components/ui/doc-present.tsx
import * as React from "react";
import { createPortal } from "react-dom";

import { cn } from "@/lib/utils";

/** The slide is laid out at this size and scaled to fit the screen, so any content keeps its proportions. */
const STAGE_WIDTH = 800;
const STAGE_HEIGHT = 450;

export interface DocPresentProps {
  /** One node per slide, in order. */
  slides: React.ReactNode[];
  /** The slide to open on. */
  start?: number;
  onClose: () => void;
  className?: string;
}

const control =
  "rounded px-3 py-1 text-sm text-text-muted hover:bg-bg-surface-2 disabled:opacity-40 pointer-coarse:min-h-11 pointer-coarse:min-w-11";

/**
 * A fullscreen slideshow over the page. Space, Arrow right and Page Down go forward; Arrow left and Page Up go back;
 * Home and End jump to the ends; `f` toggles browser fullscreen; Escape closes. The slide is scaled to fit the window,
 * and the focus returns to where it was when the show closes.
 */
function DocPresent({ slides, start = 0, onClose, className }: DocPresentProps) {
  const last = slides.length - 1;
  const [index, setIndex] = React.useState(Math.min(Math.max(start, 0), Math.max(last, 0)));
  const [scale, setScale] = React.useState(1);
  const root = React.useRef<HTMLDivElement>(null);
  const stage = React.useRef<HTMLDivElement>(null);
  const go = (to: number) => setIndex(Math.min(Math.max(to, 0), Math.max(last, 0)));

  // Take focus for the keys and stop the page behind from scrolling; undo both (and leave fullscreen) when the show ends.
  React.useEffect(() => {
    const before = document.activeElement;
    const overflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    root.current?.focus();
    return () => {
      document.body.style.overflow = overflow;
      if (document.fullscreenElement) void document.exitFullscreen();
      if (before instanceof HTMLElement) before.focus();
    };
  }, []);

  // Fit the slide: its width to the screen's, its height (taller content shrinks the slide) to the screen's.
  React.useLayoutEffect(() => {
    const fit = () => {
      const height = Math.max(STAGE_HEIGHT, stage.current?.scrollHeight ?? 0);
      const area = root.current?.getBoundingClientRect();
      if (area) setScale(Math.min(area.width / STAGE_WIDTH, (area.height - 56) / height) * 0.92);
    };
    fit();
    const observer = new ResizeObserver(fit);
    if (root.current) observer.observe(root.current);
    if (stage.current) observer.observe(stage.current);
    return () => observer.disconnect();
  }, [index]);

  const fullscreen = () => {
    if (document.fullscreenElement) void document.exitFullscreen();
    else void root.current?.requestFullscreen?.().catch(() => undefined);
  };
  const keys = (e: React.KeyboardEvent) => {
    const to: Record<string, number | undefined> = {
      " ": index + 1,
      ArrowRight: index + 1,
      PageDown: index + 1,
      ArrowLeft: index - 1,
      PageUp: index - 1,
      Home: 0,
      End: last,
    };
    if (e.key in to) (e.preventDefault(), go(to[e.key] as number));
    else if (e.key === "f" || e.key === "F") fullscreen();
    else if (e.key === "Escape") onClose();
  };

  return createPortal(
    <div
      ref={root}
      role="dialog"
      aria-modal="true"
      aria-label="Presentation"
      tabIndex={-1}
      onKeyDown={keys}
      className={cn("fixed inset-0 z-[100] flex flex-col bg-bg-main text-text-strong outline-none", className)}
    >
      <div className="relative flex-1 overflow-hidden">
        <div
          ref={stage}
          key={index}
          style={{ width: STAGE_WIDTH, minHeight: STAGE_HEIGHT, transform: `translate(-50%, -50%) scale(${scale})` }}
          className="absolute top-1/2 left-1/2 flex origin-center flex-col justify-center p-8"
        >
          {slides[index]}
        </div>
      </div>
      <div className="flex h-14 items-center justify-center gap-2 border-t border-border-default">
        <button type="button" className={control} disabled={index === 0} onClick={() => go(index - 1)}>
          Previous
        </button>
        <span aria-live="polite" className="min-w-20 text-center text-sm text-text-muted tabular-nums">
          {index + 1} / {slides.length}
        </span>
        <button type="button" className={control} disabled={index === last} onClick={() => go(index + 1)}>
          Next
        </button>
        <button type="button" className={cn(control, "ml-4")} onClick={fullscreen}>
          Fullscreen
        </button>
        <button type="button" className={control} onClick={onClose}>
          Close
        </button>
      </div>
    </div>,
    document.body,
  );
}

export { DocPresent };