Aura Design System

Presentation

Composable PowerPoint viewer and editor built on @diceui/pptx.

Preview

Loading...
import * as React from "react";
import { Button } from "@/components/ui/Button";
import {
  Presentation,
  PresentationContent,
  PresentationError,
  PresentationLoading,
  PresentationSelection,
  PresentationSlide,
  PresentationThumbnailList,
  PresentationViewport,
} from "@/components/ui/Presentation";

export function PresentationDemo() {
  return {
  const [file, setFile] = React.useState<ArrayBuffer | null>(null);
  const [error, setError] = React.useState<string | null>(null);
  const [picked, setPicked] = React.useState<File | null>(null);
  const inputRef = React.useRef<HTMLInputElement>(null);

  React.useEffect(() => {
    let cancelled = false;
    fetch(SAMPLE_PPTX)
      .then((response) => {
        if (!response.ok) {
          throw new Error("Failed to load sample pptx");
        }
        return response.arrayBuffer();
      })
      .then((buffer) => {
        if (!cancelled) setFile(buffer);
      })
      .catch((err: unknown) => {
        if (!cancelled) {
          setError(err instanceof Error ? err.message : "Failed to load sample");
        }
      });
    return () => {
      cancelled = true;
    };
  }, []);

  const source = picked ?? file;

  return (
    <div className="flex w-full flex-col gap-0.5">
      <div className="flex flex-wrap items-center gap-0.5">
        <input
          ref={inputRef}
          type="file"
          accept=".pptx,application/vnd.openxmlformats-officedocument.presentationml.presentation"
          className="sr-only"
          onChange={(event) => {
            const next = event.target.files?.[0] ?? null;
            setPicked(next);
          }}
        />
        <Button
          type="button"
          variant="pill"
          size="sm"
          onClick={() => inputRef.current?.click()}
        >
          Open .pptx
        </Button>
        {error ? (
          <span className="text-xs text-danger-contrast">{error}</span>
        ) : null}
      </div>

      <Presentation
        file={source ?? undefined}
        readOnly
        className="h-32 w-full overflow-hidden rounded-md border border-gray-6 bg-gray-1"
      >
        <PresentationThumbnailList />
        <PresentationContent>
          <PresentationLoading />
          <PresentationError />
          <PresentationViewport>
            <PresentationSlide />
          </PresentationViewport>
        </PresentationContent>
      </Presentation>
    </div>
  );
};

Installation

Make sure that namespace is set in your component.json file. Namespace docs: Learn more about namespaces

pnpm dlx shadcn@latest add @aura/presentation

Manual

Install the following dependencies:

pnpm install @diceui/pptx @radix-ui/react-icons

Copy and paste the button component into your components/ui/Button.tsx file.

/**
 * @description Displays a button or a component that looks like a button.
 */
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";

import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/class-names";

const buttonVariants = cva("button", {
  variants: {
    variant: {
      default: "button-fill",
      fill: "button-fill",
      pill: "button-pill border border-gray-6 text-gray-11 bg-gray-2 hover:bg-gray-3",
      link: "button-link",
      menu: "button-menu",
    },
    size: {
      default: "h-4",
      xs: "h-2.5",
      sm: "h-3",
      md: "h-4",
      lg: "h-5",
      xl: "h-6",
      icon: "w-3 h-3 p-0",
      "icon-md": "w-4 h-4 p-0",
    },
  },
  defaultVariants: {
    variant: "default",
    size: "default",
  },
});

interface ButtonProps
  extends React.ComponentProps<"button">,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
  isDisabled?: boolean;
  isLoading?: boolean;
  isLoadingText?: string | React.ReactNode;
  mode?: VariantProps<typeof buttonVariants>["variant"];
  label?: string | React.ReactNode;
}


const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (props: ButtonProps, ref) => {
    const {
      className,
      variant,
      mode,
      size,
      asChild = false,
      isDisabled,
      isLoading,
      isLoadingText,
      children,
      label,
      ...rest
    } = props;
    const Comp = asChild ? Slot : "button";
    const disabled = isDisabled || isLoading || props.disabled;
    const effectiveVariant = variant ?? mode;

    return (
      <Comp
        data-slot="button"
        className={cn(
          buttonVariants({ variant: effectiveVariant, size, className }),
          disabled && "opacity-50 cursor-not-allowed"
        )}
        ref={ref}
        disabled={disabled}
        {...rest}
      >
        {asChild ? (
          children
        ) : (
          <>
      
            {isLoading && isLoadingText ? isLoadingText : <>{label}{children}</>}
          </>
        )}
      </Comp>
    );
  }
);

Button.displayName = "Button";

export { Button, buttonVariants };
export type { ButtonProps };
export default Button;

Copy and paste the class names utility into your utils/class-names.ts file.

import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

Copy and paste the use mobile hook into your hooks/use-mobile.ts file.

import * as React from "react"

const MOBILE_BREAKPOINT = 768

export function useIsMobile(breakpoint = MOBILE_BREAKPOINT) {
  const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)

  React.useEffect(() => {
    const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`)
    const onChange = () => {
      setIsMobile(window.innerWidth < breakpoint)
    }
    mql.addEventListener("change", onChange)
    setIsMobile(window.innerWidth < breakpoint)
    return () => mql.removeEventListener("change", onChange)
  }, [breakpoint])

  return !!isMobile
}

Copy and paste the Presentation component into your components/ui/Presentation.tsx file.

"use client";

/**
 * @description Composable PowerPoint viewer and editor built on @diceui/pptx.
 */
import * as React from "react";
import { Presentation as PresentationPrimitive } from "@diceui/pptx";
import { ViewVerticalIcon } from "@radix-ui/react-icons";

import { cn } from "@/utils/class-names";
import { useIsMobile } from "@/hooks/use-mobile";
import { Button } from "@/components/ui/Button";

function PresentationProvider({
  ...props
}: PresentationPrimitive.Provider.Props) {
  return (
    <PresentationPrimitive.Provider
      data-slot="presentation-provider"
      {...props}
    />
  );
}

function Presentation({
  className,
  ...props
}: PresentationPrimitive.Root.Props) {
  return (
    <PresentationPrimitive.Root
      data-slot="presentation"
      className={cn("relative flex overflow-hidden", className)}
      {...props}
    />
  );
}

function PresentationContent({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="presentation-content"
      className={cn(
        "relative isolate flex flex-1 flex-col overflow-hidden",
        className
      )}
      {...props}
    />
  );
}

function PresentationViewport({
  className,
  autoFit = true,
  autoFitPadding = 13,
  ...props
}: PresentationPrimitive.Viewport.Props) {
  return (
    <PresentationPrimitive.Viewport
      autoFit={autoFit}
      autoFitPadding={autoFitPadding}
      data-slot="presentation-viewport"
      className={cn(
        "flex flex-1 items-center justify-center overflow-hidden bg-gray-2",
        className
      )}
      {...props}
    />
  );
}

function PresentationSlide({
  className,
  ...props
}: PresentationPrimitive.Slide.Props) {
  return (
    <PresentationPrimitive.Slide
      data-slot="presentation-slide"
      className={cn("border border-gray-6", className)}
      {...props}
    />
  );
}

function PresentationSelection({
  className,
  ...props
}: PresentationPrimitive.Selection.Props) {
  return (
    <PresentationPrimitive.Selection
      data-slot="presentation-selection"
      className={cn("[--presentation-selection:var(--accent-8)]", className)}
      {...props}
    />
  );
}

function PresentationLoading({
  className,
  children,
  ...props
}: PresentationPrimitive.Loading.Props) {
  return (
    <PresentationPrimitive.Loading
      data-slot="presentation-loading"
      className={cn(
        "absolute inset-0 z-10 flex items-center justify-center text-sm text-gray-11",
        className
      )}
      {...props}
    >
      {children ?? ((progress) => <span>Loading… {progress}%</span>)}
    </PresentationPrimitive.Loading>
  );
}

function PresentationError({
  className,
  children,
  ...props
}: PresentationPrimitive.Error.Props) {
  return (
    <PresentationPrimitive.Error
      data-slot="presentation-error"
      className={cn(
        "absolute inset-0 z-10 flex items-center justify-center text-sm text-danger-contrast",
        className
      )}
      {...props}
    >
      {children ?? ((error: Error) => <span>{error.message}</span>)}
    </PresentationPrimitive.Error>
  );
}

function PresentationThumbnailItemPreview({
  className,
  ...props
}: PresentationPrimitive.ThumbnailItemPreview.Props) {
  return (
    <PresentationPrimitive.ThumbnailItemPreview
      data-slot="presentation-thumbnail-item-preview"
      className={cn(
        "w-full overflow-hidden rounded-sm data-pending:animate-pulse data-pending:bg-gray-3",
        className
      )}
      {...props}
    />
  );
}

function PresentationThumbnailItemNumber({
  className,
  ...props
}: PresentationPrimitive.ThumbnailItemNumber.Props) {
  return (
    <PresentationPrimitive.ThumbnailItemNumber
      data-slot="presentation-thumbnail-item-number"
      className={cn(
        "block shrink-0 text-center font-mono text-xs leading-none text-gray-11 tabular-nums",
        className
      )}
      {...props}
    />
  );
}

function PresentationThumbnailItem({
  className,
  children,
  ...props
}: PresentationPrimitive.ThumbnailItem.Props) {
  return (
    <PresentationPrimitive.ThumbnailItem
      data-slot="presentation-thumbnail-item"
      className={cn(
        "relative flex w-full cursor-pointer gap-0.5 rounded-md border border-gray-6 p-0.5 outline-none hover:bg-gray-3",
        "ring-2 ring-transparent transition-[color,box-shadow] duration-100 focus-visible:ring-accent-8 data-active:bg-gray-3",
        className
      )}
      {...props}
    >
      {children ?? (
        <>
          <PresentationThumbnailItemNumber />
          <PresentationThumbnailItemPreview />
        </>
      )}
    </PresentationPrimitive.ThumbnailItem>
  );
}

function PresentationThumbnailList({
  className,
  children,
  ...props
}: PresentationPrimitive.ThumbnailList.Props) {
  const isMobile = useIsMobile();
  const id = React.useId();
  const [open, setOpen] = React.useState(false);
  const [wasMobile, setWasMobile] = React.useState(isMobile);
  const listRef = React.useRef<HTMLDivElement | null>(null);
  const triggerRef = React.useRef<HTMLButtonElement | null>(null);

  const isHidden = isMobile && !open;

  if (wasMobile !== isMobile) {
    setWasMobile(isMobile);
    setOpen(false);
  }

  React.useEffect(() => {
    if (!isMobile || !open) return;

    let pendingPointerId: number | null = null;

    function isInside(target: Node) {
      return (
        !!listRef.current?.contains(target) ||
        !!triggerRef.current?.contains(target)
      );
    }

    function onPointerDown(event: PointerEvent) {
      pendingPointerId = isInside(event.target as Node)
        ? null
        : event.pointerId;
    }

    function onPointerUp(event: PointerEvent) {
      const isPendingPointer = pendingPointerId === event.pointerId;
      pendingPointerId = null;
      if (isPendingPointer && !isInside(event.target as Node)) setOpen(false);
    }

    function onPointerCancel() {
      pendingPointerId = null;
    }

    function onKeyDown(event: KeyboardEvent) {
      if (event.key === "Escape") setOpen(false);
    }

    document.addEventListener("pointerdown", onPointerDown);
    document.addEventListener("pointerup", onPointerUp);
    document.addEventListener("pointercancel", onPointerCancel);
    document.addEventListener("keydown", onKeyDown);
    return () => {
      document.removeEventListener("pointerdown", onPointerDown);
      document.removeEventListener("pointerup", onPointerUp);
      document.removeEventListener("pointercancel", onPointerCancel);
      document.removeEventListener("keydown", onKeyDown);
    };
  }, [isMobile, open]);

  return (
    <>
      {isMobile && (
        <Button
          type="button"
          aria-controls={id}
          aria-expanded={open}
          aria-label="Toggle thumbnails"
          variant="pill"
          size="icon"
          ref={triggerRef}
          className="absolute top-0.5 left-0.5 z-10 bg-gray-1/80 backdrop-blur-sm"
          onClick={() => setOpen((value) => !value)}
        >
          <ViewVerticalIcon className="icon" aria-hidden />
        </Button>
      )}
      <PresentationPrimitive.ThumbnailList
        id={id}
        aria-hidden={isHidden || undefined}
        data-slot="presentation-thumbnail-list"
        data-mobile={isMobile || undefined}
        inert={isHidden}
        ref={listRef}
        render={<aside />}
        className={cn(
          "flex w-12 shrink-0 flex-col gap-0.5 overflow-y-auto border-r border-gray-6 bg-gray-1 p-0.5",
          isMobile && "absolute inset-y-0 left-0 z-20 transition-transform",
          isHidden && "-translate-x-full",
          className
        )}
        {...props}
      >
        {children ??
          (({ slides }) =>
            slides.map((slide) => (
              <PresentationThumbnailItem key={slide.id} slideId={slide.id} />
            )))}
      </PresentationPrimitive.ThumbnailList>
    </>
  );
}

export {
  Presentation,
  PresentationContent,
  PresentationError,
  PresentationLoading,
  PresentationProvider,
  PresentationSelection,
  PresentationSlide,
  PresentationThumbnailItem,
  PresentationThumbnailItemNumber,
  PresentationThumbnailItemPreview,
  PresentationThumbnailList,
  PresentationViewport,
};

export {
  useCreatePresentationStore,
  useHistory,
  usePresentation,
  usePresentationStore,
  useSlide,
  useZoom,
} from "@diceui/pptx";

Usage

Editing

export const Editing = () => {
  const [file, setFile] = React.useState<ArrayBuffer | null>(null);
  const [error, setError] = React.useState<string | null>(null);
  const [picked, setPicked] = React.useState<File | null>(null);
  const inputRef = React.useRef<HTMLInputElement>(null);

  React.useEffect(() => {
    let cancelled = false;
    fetch(SAMPLE_PPTX)
      .then((response) => {
        if (!response.ok) {
          throw new Error("Failed to load sample pptx");
        }
        return response.arrayBuffer();
      })
      .then((buffer) => {
        if (!cancelled) setFile(buffer);
      })
      .catch((err: unknown) => {
        if (!cancelled) {
          setError(err instanceof Error ? err.message : "Failed to load sample");
        }
      });
    return () => {
      cancelled = true;
    };
  }, []);

  const source = picked ?? file;

  return (
    <div className="flex w-full flex-col gap-0.5">
      <div className="flex flex-wrap items-center gap-0.5">
        <input
          ref={inputRef}
          type="file"
          accept=".pptx,application/vnd.openxmlformats-officedocument.presentationml.presentation"
          className="sr-only"
          onChange={(event) => {
            const next = event.target.files?.[0] ?? null;
            setPicked(next);
          }}
        />
        <Button
          type="button"
          variant="pill"
          size="sm"
          onClick={() => inputRef.current?.click()}
        >
          Open .pptx
        </Button>
        {error ? (
          <span className="text-xs text-danger-contrast">{error}</span>
        ) : null}
      </div>

      <Presentation
        file={source ?? undefined}
        readOnly={false}
        className="h-32 w-full overflow-hidden rounded-md border border-gray-6 bg-gray-1"
      >
        <PresentationThumbnailList />
        <PresentationContent>
          <PresentationLoading />
          <PresentationError />
          <PresentationViewport>
            <PresentationSlide>
              <PresentationSelection undoRedoShortcuts />
            </PresentationSlide>
          </PresentationViewport>
        </PresentationContent>
      </Presentation>
    </div>
  );
};