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-presentpnpm dlx velaui add @velaui/doc-presentyarn dlx shadcn@latest add @velaui/doc-presentbunx --bun shadcn@latest add @velaui/doc-presentveni add doc-presentInstalls the copy of vela-ui that veni-builder serves to Venizia apps; veni-builder serves the Radix UI copy only, so this tab exists for that base alone.
npx shadcn@latest add @velaui/doc-presentpnpm dlx velaui add @velaui/doc-presentyarn dlx shadcn@latest add @velaui/doc-presentbunx --bun shadcn@latest add @velaui/doc-presentExamples
- Default
Usage
Props
| Prop | Type | Default |
|---|---|---|
className | string | — |
onCloserequired | () => void | — |
slidesrequired | React.ReactNode[] | — |
start | number | 0 |
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 };