shadimage
glasscn
componentcomponent-previewsource: ok · refreshed

Component Preview

A preview shell for component demos with switchable default and glass variants.

npx shadcn add "https://glasscn-components.vercel.app/r/component-preview.json"
free

Where it goes

testimonial

Visual style

glass-blur

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
iconslucide-react
animationmotion

Registry dependencies

@glasscn/utils@glasscn/button@glasscn/card@glasscn/separator@glasscn/glass-button@glasscn/glass-card@glasscn/glass-variants

Files5 · source cached 10/8/2026, 9:23:39 AM

components/component-preview-tabs.tsx
"use client";

import { ChevronUpIcon } from "lucide-react";
import * as React from "react";

import { CopyButton } from "@/components/custom/copy-button";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";

import { Card } from "./ui/card";

export type ComponentPreviewTabsProps = React.ComponentProps<"div"> & {
  previewClassName?: string;
  align?: "center" | "start" | "end";
  hideCode?: boolean;
  component: React.ReactNode;
  code: string;
  highlightedCode: string;
  previewHighlightedCode: string;
};

export function ComponentPreviewTabs({
  className,
  previewClassName,
  align = "center",
  hideCode = false,
  component,
  code,
  highlightedCode,
  previewHighlightedCode,
}: ComponentPreviewTabsProps) {
  const [codeVisible, setCodeVisible] = React.useState(false);

  return (
    <Card data-slot="component-preview" className={cn("mt-4 mb-12 gap-0 overflow-hidden py-0", className)}>
      <div data-slot="preview" dir="ltr">
        <div
          data-align={align}
          className={cn(
            "preview relative flex min-h-72 w-full justify-center p-10 data-[align=center]:items-center data-[align=end]:items-end data-[align=start]:items-start",
            previewClassName,
          )}
        >
          {component}
        </div>
      </div>
      {!hideCode && (
        <>
          <Separator />
          <div data-slot="code" data-code-visible={codeVisible} className="group/code bg-background relative">
            <div className="absolute top-4 right-4 z-10 flex items-center gap-2">
              {codeVisible && (
                <Button
                  type="button"
                  size="icon"
                  variant="outline"
                  className="text-foreground/80 hover:text-foreground h-7 w-7"
                  onClick={() => setCodeVisible(false)}
                >
                  <ChevronUpIcon className="h-3.5 w-3.5" />
                  <span className="sr-only">Collapse code</span>
                </Button>
              )}
              <CopyButton
                text={code}
                size="icon"
                variant="outline"
                className={cn(
                  "h-7 w-7 text-foreground/80 hover:text-foreground [&_svg]:h-3.5 [&_svg]:w-3.5",
                  !codeVisible && "bg-background/50",
                )}
              />
            </div>
            {codeVisible ? (
              <div className="[&_pre]:max-h-96" dangerouslySetInnerHTML={{ __html: highlightedCode }} />
            ) : (
              <div className="relative">
                <div
                  className="max-h-28 overflow-hidden [&_pre]:overflow-hidden"
                  dangerouslySetInnerHTML={{ __html: previewHighlightedCode }}
                />
                <div className="from-background via-background/70 dark:via-background/80 absolute inset-0 flex items-end justify-center bg-linear-to-t to-transparent pb-4">
                  <Button type="button" size="sm" variant="outline" onClick={() => setCodeVisible(true)}>
                    View Code
                  </Button>
                </div>
              </div>
            )}
          </div>
        </>
      )}
    </Card>
  );
}
components/glass-component-preview-tabs.tsx
"use client";

import { ChevronUpIcon } from "lucide-react";
import * as React from "react";

import { CopyButton } from "@/components/custom/copy-button";
import { Card } from "@/components/ui/card";
import { GlassButton } from "@/components/ui/glasscn/glass-button";
import { GlassCard } from "@/components/ui/glasscn/glass-card";
import type { FrostGlassVariant } from "@/lib/glass-variants";
import { cn } from "@/lib/utils";

export type GlassComponentPreviewTabsProps = React.ComponentProps<"div"> & {
  previewClassName?: string;
  align?: "center" | "start" | "end";
  hideCode?: boolean;
  component: React.ReactNode;
  code: string;
  highlightedCode: string;
  previewHighlightedCode: string;
  previewGlassVariant?: FrostGlassVariant;
  codeGlassVariant?: FrostGlassVariant;
};

export function GlassComponentPreviewTabs({
  className,
  previewClassName,
  align = "center",
  hideCode = false,
  component,
  code,
  highlightedCode,
  previewHighlightedCode,
  codeGlassVariant = "frosted",
}: GlassComponentPreviewTabsProps) {
  const [codeVisible, setCodeVisible] = React.useState(false);

  const codePanel = (
    <>
      <div className="absolute top-4 right-4 z-10 flex items-center gap-2">
        {codeVisible && (
          <GlassButton
            type="button"
            size="icon"
            variant="outline"
            glassVariant="liquid-refract"
            className="text-foreground/80 hover:text-foreground h-7 w-7"
            onClick={() => setCodeVisible(false)}
          >
            <ChevronUpIcon className="h-3.5 w-3.5" />
            <span className="sr-only">Collapse code</span>
          </GlassButton>
        )}
        <CopyButton
          text={code}
          size="icon"
          variant="outline"
          className={cn(
            "h-7 w-7 text-foreground/80 hover:text-foreground [&_svg]:h-3.5 [&_svg]:w-3.5",
            !codeVisible && "bg-background/40",
          )}
        />
      </div>

      {codeVisible ? (
        <div className="[&_pre]:max-h-96" dangerouslySetInnerHTML={{ __html: highlightedCode }} />
      ) : (
        <div className="relative">
          <div
            className="max-h-28 overflow-hidden [&_pre]:overflow-hidden"
            dangerouslySetInnerHTML={{ __html: previewHighlightedCode }}
          />
          <div className="absolute inset-0 flex items-end justify-center bg-linear-to-t from-white/55 via-white/25 to-transparent pb-4 dark:from-black/70 dark:via-black/35">
            <GlassButton
              type="button"
              size="sm"
              variant="outline"
              glassVariant="liquid-refract"
              onClick={() => setCodeVisible(true)}
            >
              View Code
            </GlassButton>
          </div>
        </div>
      )}
    </>
  );

  return (
    <div className={cn("mt-4 mb-12 flex flex-col gap-0", className)}>
      <Card
        data-slot="glass-component-preview"
        variant="outline"
        className="gap-0 overflow-hidden rounded-t-[28px] rounded-b-none bg-transparent py-0"
      >
        <div data-slot="preview" dir="ltr">
          <div
            data-align={align}
            className={cn(
              "preview relative flex min-h-72 w-full justify-center p-10 data-[align=center]:items-center data-[align=end]:items-end data-[align=start]:items-start",
              previewClassName,
            )}
          >
            {component}
          </div>
        </div>
      </Card>

      {!hideCode && (
        <GlassCard
          data-slot="glass-component-code"
          glassVariant={codeGlassVariant}
          className="group/code relative -mt-px gap-0 overflow-hidden rounded-t-none rounded-b-[28px] py-0"
        >
          {codePanel}
        </GlassCard>
      )}
    </div>
  );
}
components/custom/copy-button.tsx
"use client";

import { CheckIcon, CircleXIcon, CopyIcon } from "lucide-react";
import type { HTMLMotionProps, Variants } from "motion/react";
import { AnimatePresence, motion } from "motion/react";
import type { ComponentProps } from "react";

import { Button } from "@/components/ui/button";
import type { CopyState } from "@/hooks/use-copy-to-clipboard";
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";

export const motionIconVariants: Variants = {
  initial: { opacity: 0, scale: 0.8, filter: "blur(2px)" },
  animate: { opacity: 1, scale: 1, filter: "blur(0px)" },
  exit: { opacity: 0, scale: 0.8 },
};

export const motionIconProps: HTMLMotionProps<"span"> = {
  variants: motionIconVariants,
  initial: "initial",
  animate: "animate",
  exit: "exit",
  transition: { duration: 0.25, ease: "easeOut" },
};

export type CopyStateIconProps = {
  state: CopyState;
  idleIcon?: React.ReactNode;
  doneIcon?: React.ReactNode;
  errorIcon?: React.ReactNode;
};

export function CopyStateIcon({ state, idleIcon, doneIcon, errorIcon }: CopyStateIconProps) {
  return (
    <AnimatePresence mode="wait" initial={false}>
      {state === "idle" ? (
        <motion.span key="idle" {...motionIconProps}>
          {idleIcon ?? <CopyIcon />}
        </motion.span>
      ) : state === "done" ? (
        <motion.span key="done" {...motionIconProps}>
          {doneIcon ?? <CheckIcon strokeWidth={3} color="green" />}
        </motion.span>
      ) : state === "error" ? (
        <motion.span key="error" {...motionIconProps}>
          {errorIcon ?? <CircleXIcon />}
        </motion.span>
      ) : null}
    </AnimatePresence>
  );
}

export type CopyButtonProps = ComponentProps<typeof Button> & {
  text: string | (() => string);
  onCopySuccess?: (text: string) => void;
  onCopyError?: (error: Error) => void;
} & Pick<CopyStateIconProps, "idleIcon" | "doneIcon" | "errorIcon">;

export function CopyButton({
  size = "icon",
  variant = "ghost",
  children,
  text,
  idleIcon,
  doneIcon,
  errorIcon,
  onClick,
  onCopySuccess,
  onCopyError,
  ...props
}: CopyButtonProps) {
  const { state, copy } = useCopyToClipboard({ onCopySuccess, onCopyError });

  return (
    <Button
      size={size}
      variant={variant}
      onClick={(e) => {
        void copy(text);
        onClick?.(e);
      }}
      aria-label="Copy"
      {...props}
    >
      <CopyStateIcon state={state} idleIcon={idleIcon} doneIcon={doneIcon} errorIcon={errorIcon} />
      {children}
    </Button>
  );
}
hooks/use-copy-to-clipboard.ts
import { useState } from "react";

export type CopyState = "idle" | "done" | "error";

type UseCopyToClipboardOptions = {
  onCopySuccess?: (text: string) => void;
  onCopyError?: (error: Error) => void;
  timeout?: number;
};

export function useCopyToClipboard({ onCopySuccess, onCopyError, timeout = 2000 }: UseCopyToClipboardOptions = {}) {
  const [state, setState] = useState<CopyState>("idle");

  async function copy(text: string | (() => string)) {
    const value = typeof text === "function" ? text() : text;
    try {
      await navigator.clipboard.writeText(value);
      setState("done");
      onCopySuccess?.(value);
    } catch (err) {
      setState("error");
      onCopyError?.(err instanceof Error ? err : new Error(String(err)));
    } finally {
      setTimeout(() => setState("idle"), timeout);
    }
  }

  return { state, copy };
}
lib/highlight-code.ts
import { createHash } from "node:crypto";

import { codeToHtml } from "shiki";

// Highlighting is deterministic for a given source string + language, so we can
// safely memoize the generated HTML in-process and avoid repeating the Shiki work
// on every request during local dev or while the server stays warm.
const highlightCache = new Map<string, string>();

export async function highlightCode(code: string, language: string = "tsx") {
  // Use the content itself as the cache input so repeated component docs pages
  // can reuse the exact same highlighted payload without caring which route asked
  // for it.
  const cacheKey = createHash("sha256").update(`${language}:${code}`).digest("hex");

  const cached = highlightCache.get(cacheKey);

  if (cached) {
    return cached;
  }

  // Shiki runs fine on the server: it tokenizes the source code and returns an
  // HTML string with theme variables and token spans already applied. That lets
  // the page ship highlighted markup on first paint instead of waiting for a
  // client-side effect to colorize plain text after hydration.
  const html = await codeToHtml(code, {
    lang: language,
    themes: { dark: "github-dark", light: "github-light" },
    transformers: [
      {
        pre(node) {
          node.properties.class =
            "shiki no-scrollbar min-w-0 overflow-x-auto overflow-y-auto overscroll-x-contain overscroll-y-auto p-4 pr-16 text-sm leading-6";
        },
        code(node) {
          node.properties["data-line-numbers"] = "";
        },
        line(node) {
          node.properties["data-line"] = "";
        },
      },
    ],
  });

  // The caller renders this HTML payload directly. We keep the wrapper classes
  // here so every preview gets the same structure and scrolling behavior from one
  // place instead of rebuilding that shape at each call site.
  highlightCache.set(cacheKey, html);

  return html;
}

More from glasscn

Browse all components in this library →