Build an Interactive Code Preview

Build an Interactive Code Preview

How to create a component preview featuring automated code-block generation with Next.js and Typescript

·

11 min read

Introduction

Project Overview

In this article, we're going to be building a component preview featuring:

  • Tabs to switch between visual preview and source code

  • Drag-to-resize handles

  • Real-time component dimensions

  • Auto-generated source code

  • Dark/light mode support

Let's get started!

Getting Started

Setup

If you'd like to code along, create a Next.js project with the following command. If you're using Yarn, pnpm, or Bun as your package manager, see here for their equivalent commands.

npx create-next-app@latest

During the interactive installation, you'll be prompted to answer a few questions. I recommend the following configuration:

  • What is your project named? code-preview

  • Would you like to use TypeScript? Yes

  • Would you like to use ESLint? Yes

  • Would you like to use Tailwind CSS? Yes

  • Would you like to use src/ directory? No

  • Would you like to use App Router? (recommended) Yes

  • Would you like to customize the default import alias (@/*)? No

Install Dependencies

We're going to be using a few external packages during the course of this project:

Let's install them with the following command:

npm i @tailwindcss/container-queries bright lucide-react next-themes react-resizable-panels usehooks-ts

While we're in the terminal, let's also install the UI components we'll be using from shadcn/ui:

npx shadcn-ui@latest init
npx shadcn-ui@latest add accordion button card sonner tabs

Project Structure

In case it is helpful, here is what our project structure will look like when we are done. This is the general structure that I tend to follow in all of my Next.js projects.

.
├── app
│   ├── layout.tsx
│   └── page.tsx
├── components
│   ├── demo
│   │   └── accordion-demo.tsx
│   ├── providers
│   │   └── theme-provider.tsx
│   ├── ui
│   │   ├── accordion.tsx
│   │   ├── button.tsx
│   │   ├── card.tsx
│   │   ├── sonner.tsx
│   │   └── tabs.tsx
│   ├── demo-code.tsx
│   ├── demo-preview.tsx
│   ├── demo-toolbar.tsx
│   ├── demo.tsx
│   ├── site-header.tsx
│   └── theme-toggle.tsx
├── lib
│   └── ...
├── public
│   └── ...
├── README.md
├── components.json
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── tailwind.config.ts
└── tsconfig.json

Implementation

Site Header

For this project, we're only going to be using the site header to display the theme toggle which will enable users to switch between light and dark modes.

Create the theme toggle component in the root of the @/components/ directory:

// @/components/theme-toggle.tsx

"use client"

import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"

import { Button } from "@/components/ui/button"

export const ThemeToggle = () => {
  const { setTheme, theme } = useTheme()

  return (
    <Button
      variant="ghost"
      size="icon"
      onClick={() => setTheme(theme === "light" ? "dark" : "light")}
    >
      <Sun className="h-[1.5rem] w-[1.3rem] dark:hidden" />
      <Moon className="hidden h-5 w-5 dark:block" />
      <span className="sr-only">Toggle theme</span>
    </Button>
  )
}

Create a new file named site-header.tsx in the root of the @/components/ directory:

// @/components/site-header.tsx

"use client"

import { ThemeToggle } from "@/components/theme-toggle";

export const SiteHeader = () => {
    return (
        <header className="fixed w-full top-0 z-50 bg-background border-b">
            <div className="container max-w-screen-2xl flex h-16 items-center justify-between">
                <div className="text-2xl">Code Preview</div>
                <ThemeToggle />
            </div>
        </header>
    )
}

Theme Provider

Even though we have now created the theme toggle and site header components, they won't be very useful unless they have a way to interact with the theme context.

To do that, let's create a theme provider which we'll use in just a moment to wrap our root layout.

Create the new directory @/components/providers/ and then in that directory create a new file named theme-provider.tsx:

// @/components/providers/theme-provider.tsx

"use client"

import { ThemeProvider as NextThemesProvider } from "next-themes"
import { type ThemeProviderProps } from "next-themes/dist/types"

export function ThemeProvider({ 
  children, ...props 
}: ThemeProviderProps) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

Root Layout

We can now add the theme provider to our root layout located at @/app/layout.tsx and also add our toast provider so that we can display ephemeral notifications to our users:

import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "@/styles/globals.css";
import { cn } from "@/lib/utils";
import { ThemeProvider } from "@/components/providers/theme-provider";
import { SiteHeader } from "@/components/site-header";
import { Toaster } from "@/components/ui/sonner";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Code Preview",
  description: "An interactive code preview built with Next.js and Typescript.",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body 
        className={cn(
          "min-h-screen bg-background",
          inter.className
        )}
      >
        <ThemeProvider
          attribute="class"
          defaultTheme="system"
          enableSystem={true}
          disableTransitionOnChange
        >
          <SiteHeader />
          <main className="w-full max-w-screen-2xl mx-auto">
              <div className="relative pt-16 h-full flex flex-row gap-4">
                 {children}
              </div>
          </main>
          <Toaster position="top-center"/>
        </ThemeProvider>
      </body>
    </html>
  );
}

Display Component

This is the component or UI element that we want to display in our demo. I'm going to use an extended accordion component, but you can display whatever you'd like here!

// @/components/demo/accordion-demo.tsx

"use client"

import { useState } from "react";
import Image from "next/image";
import { cn } from "@/lib/utils";
import { 
    Accordion, 
    AccordionContent, 
    AccordionItem, 
    AccordionTrigger 
} from "@/components/ui/accordion";

const AccordionDemo = () => {
    const [active, setActive] = useState<number | null>(1);

    return (
        <div className="@container grid grid-cols-3 grid-rows-2 @3xl:grid-rows-1 items-stretch bg-background">
            <div className="relative h-auto w-auto col-span-3 @3xl:col-span-2">
                {[1, 2, 3].map((n) => (
                    <Image
                        key={n}
                        src={`/unsplash-abstract-${n}.jpeg`}
                        alt="placeholder"
                        fill
                        className={cn(
                            active === n ? "opacity-100" : "opacity-0",
                            "absolute inset-0 object-cover transition-opacity duration-500 ease-in-out",
                        )}
                    />
                ))}
            </div>
            <div className="col-span-3 @3xl:col-span-1">
                <Accordion 
                    type="single" 
                    defaultValue="1" 
                    className="flex flex-col items-stretch h-full border-t border-x"
                >
                    {[1, 2, 3].map((n) => (
                        <AccordionItem 
                            key={n}
                            value={n.toString()}
                            className={cn(
                                "relative grow flex flex-col justify-center text-lg group", 
                                "before:content[''] before:w-[2px] before:absolute before:top-0 before:left-0", 
                                "before:bg-foreground before:transition-all before:ease-in-out before:duration-500 ",
                                active === n 
                                    ? "before:h-full before:opacity-100"
                                    : "before:h-0 before:opacity-0"
                            )}
                        >
                            <AccordionTrigger
                                onClick={() => setActive(n)}
                                className="text-muted-foreground data-[state=open]:text-foreground p-6 
                                group-hover:text-foreground !no-underline transition-colors ease-in-out duration-500"
                            >
                                Accordion {n}
                            </AccordionTrigger>
                            <AccordionContent
                                className="p-6 pt-0"
                            >
                                Lorem ipsum dolor sit amet, consectetur adipiscing elit, 
                                sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
                            </AccordionContent>
                        </AccordionItem>
                    ))}
                </Accordion>
            </div>
        </div>
    )
}

export default AccordionDemo;

Demo Component

This is the main demo component which is going to serve as a wrapper for both the Demo Code and Demo Preview components. It is responsible for importing the appropriate component for the preview, as well as reading the source code from the component file. I'll elaborate on both of these functionalities below.

// @/components/demo.tsx

import fs from "fs";
import path from "path";
import { lazy, useMemo } from "react";
import {
    Tabs,
    TabsContent,
    TabsList,
    TabsTrigger
} from "@/components/ui/tabs";
import { DemoCode } from "@/components/demo-code";
import { DemoPreview } from "@/components/demo-preview";
import { DemoToolbar } from "@/components/demo-toolbar";

interface DemoProps{
    name: string,
}

export const Demo = async ({
    name,
}: DemoProps) => {

    const Preview = useMemo(() => {
        const Component = lazy(() => import("@/components/demo/accordion-demo"))

        if (!Component) {
            return (
                <p className="text-muted-foreground">
                    Component{" "}
                    <code className="relative rounded bg-muted p-1 font-mono">
                        {name}
                    </code>{" "}
                    not found.
                </p>
            )
        }

        return <Component />
    }, [name])

    let Code;

    try {
        const src = "components/demo/accordion-demo.tsx"
        const filePath = path.join(process.cwd(), src)
        Code = fs.readFileSync(filePath, "utf8")
    } catch (error) {
        console.error(error)
    }

    return (
        <div className="flex h-full w-full py-10 m-auto items-start justify-start">
            <Tabs defaultValue="preview" className="w-full">
                <div className="flex items-center justify-between gap-4">
                    <TabsList>
                        <TabsTrigger value="preview">Preview</TabsTrigger>
                        <TabsTrigger value="code">Code</TabsTrigger>
                    </TabsList>
                    <DemoToolbar copyText={Code || ""} />
                </div>
                <TabsContent value="preview">
                    <DemoPreview>
                        {Preview}
                    </DemoPreview>
                </TabsContent>
                <TabsContent value="code" className="w-full border rounded-md max-h-[500px] overflow-y-auto">
                    <DemoCode
                        title={`${name}.tsx`}
                        lang="tsx"
                        code={Code || "Failed to load code"}
                        lineNumbers={true}
                        className="!my-0"
                    />
                </TabsContent>
            </Tabs>
        </div>
    )
}

Importing the Preview Component

Rather than importing accordion-demo.tsx in the top-level file imports, we are using a useMemo hook to retrieve and the file and cache the result between re-renders. This is a more extensible pattern in the event that your project has many different components to choose from for the preview.

const Preview = useMemo(() => {
    const Component = lazy(() => import("@/components/demo/accordion-demo"))

    // error handling

    return <Component />
}, [name])

The nice thing about this pattern is that you can easily abstract it. Say you have a folder for all of the components you want to showcase. Simply add an index file where you define the name and file path for each component. Then you can refactor the above code to:

import { Index } from "@/components";

// ...

const Preview = useMemo(() => {
    const Component = Index[name].component

    // error handling

    return <Component />
}, [name])

// ...

Generating the Source Code

Since this is a server-side component, we can use fs to read the contents of the display component file and pass the resulting string to our Demo Code component in a later step.

// ...

let Code;

try {
    const src = "components/demo/accordion-demo.tsx"
    const filePath = path.join(process.cwd(), src)
    Code = fs.readFileSync(filePath, "utf8")
} catch (error) {
    console.error(error)
}

// ...

Again, this pattern can be abstracted when dealing with multiple display components. Since name is a required prop for the Demo component, we can use this prop to reference a key in an index file and return a file path. The refactored code would look something like this:

import { Index } from "@/components"

// ...

let Code;

try {
    const src = "components/demo/accordion-demo.tsx"
    const filePath = path.join(process.cwd(), src)
    Code = fs.readFileSync(filePath, "utf8")
} catch (error) {
    console.error(error)
}

// ...

Demo Preview Component

This is where most of the interactivity takes place. I'll break down the different functions below.

// @/components/demo-preview.tsx

"use client"

import { useEffect, useRef, useState } from "react";

import { useResizeObserver } from "usehooks-ts";
import { 
    ImperativePanelGroupHandle, 
    PanelResizeHandle,
    PanelGroup, 
    Panel,
} from "react-resizable-panels";

import { Button } from "@/components/ui/button";
import { 
    Card, 
    CardContent, 
    CardHeader 
} from "@/components/ui/card";
import { 
    Laptop, 
    Smartphone, 
    Tablet 
} from "lucide-react";
import { cn } from "@/lib/utils";

type Layout = [number, number, number];

interface Size {
    width?: number;
    height?: number;
}

interface Device {
    name: "desktop" | "tablet" | "smartphone" | undefined;
    size: Size;
    layout: Layout;
    icon: React.ReactNode;
    disabled: boolean;
}

const devices: {[key: string]: Device} = {
    desktop: {
        name: "desktop",
        size: { width: 1024 },
        layout: [0,100,0],
        icon: <Laptop className="h-4 w-4" />,
        disabled: false,
    },
    tablet: {
        name: "tablet",
        size: { width: 768 },
        layout: [20,60,20],
        icon: <Tablet className="h-4 w-4" />,
        disabled: false,
    },
    smartphone: {
        name: "smartphone",
        size: { width: 384 },
        layout: [30,40,30],
        icon: <Smartphone className="h-4 w-4" />,
        disabled: false,
    },
} 

export const DemoPreview = ({
    children,
}: {
    children: React.ReactNode,
}) => { 

    const [device, setDevice] = useState<Device["name"]>(undefined);
    const [panelSize, setPanelSize] = useState<Size>({
        width: undefined,
        height: undefined,
    });
    const [containerSize, setContainerSize] = useState<Size>({
        width: undefined,
        height: undefined,
    });

    const panelGroupRef = useRef<ImperativePanelGroupHandle>(null)
    const panelRef = useRef<HTMLDivElement>(null)
    const previewContainerRef = useRef<HTMLDivElement>(null)

    useResizeObserver({
        ref: previewContainerRef,
        box: 'content-box',
        onResize: setContainerSize,
    })

    useResizeObserver({
        ref: panelRef,
        box: 'content-box',
        onResize: setPanelSize,
    })

    const calculateLayouts = (containerWidth: number) => {
        type Width = Size["width"];
        const smartphonePanel: Width = devices["smartphone"].size.width! / containerWidth * 100;
        const tabletPanel: Width = devices["tablet"].size.width! / containerWidth * 100
        const smartphoneLayout: Layout = [
            (100 - smartphonePanel) / 2,
            smartphonePanel,
            (100 - smartphonePanel) / 2,
        ];
        const tabletLayout: Layout = [
            (100 - tabletPanel) / 2,
            tabletPanel,
            (100 - tabletPanel) / 2,
        ];
        devices["smartphone"].layout = smartphoneLayout;
        devices["tablet"].layout = tabletLayout;
    }

    useEffect(() => {
        const { width, height } = containerSize;
        if (!width || !height) return;
        setDevice(undefined);
        calculateLayouts(width);
        switch (true) {
            case ( width <= ( 768 + 24 ) ):
                devices["tablet"].disabled = true;
                devices["desktop"].disabled = true;
                break;
            case ( width > ( 768 + 24 ) && width <= ( 1024 + 24 ) ):
                devices["tablet"].disabled = false;
                devices["desktop"].disabled = true;
                break;
            case ( width > ( 1024 + 24 ) ):
                devices["tablet"].disabled = false;
                devices["desktop"].disabled = false;
            default:
                break;
        }
    }, [containerSize])

    const resetLayout = (layout: Layout) => {
        const panelGroup = panelGroupRef.current;
        if (!panelGroup) return;
        panelGroup.setLayout(layout)
    }

    const handleClick = (device: Exclude<Device["name"], undefined>) => {
        const layout = devices[device].layout;        
        resetLayout(layout);
        setDevice(device);
    }

    return (
        <Card className="bg-dot-grid bg-top">
            <CardHeader className="border-b py-4 px-10 bg-background rounded-t-lg">
                <div className="flex items-center justify-between">
                    <div className="space-x-2">  
                        {Object.keys(devices).map((d) => {
                            const { name, icon, disabled } = devices[d];
                            return (
                                <Button 
                                    key={name}
                                    size="icon"
                                    variant={device === name ? "default" : "outline"}
                                    onClick={() => handleClick(name!)}
                                    disabled={disabled}
                                >
                                    {icon}
                                </Button>
                            )
                        })}
                    </div>
                    <div>
                        { ( panelSize.width && panelSize.height ) && (
                            <p>{Math.round(panelSize.width)} x {Math.round(panelSize.height)}</p>
                        )}
                    </div>
                </div>
            </CardHeader>
            <CardContent 
                ref={previewContainerRef}
                className="py-8 h-[600px] bg-foreground/5"
            >
                <PanelGroup
                    id="panel-group"
                    ref={panelGroupRef}
                    direction="horizontal"
                    className="items-center"
                    onLayout = {() => setDevice(undefined)}
                >
                    <Panel defaultSize={0} />
                    <PanelResizeHandle 
                        className="w-1.5 h-8 mr-1.5 my-auto 
                        bg-muted-foreground rounded-full" 
                    />
                    <Panel 
                        id="main-panel"
                        defaultSize={100} 
                        className={cn(
                            "min-w-96",
                            device === "tablet" && "min-w-[770px]",
                        )}
                    >
                        <div 
                            ref={panelRef}
                            className="max-h-[500px] flex flex-col justify-center 
                            overflow-auto bg-[url('/dot-grid.svg')] bg-center"
                        >
                            <div 
                                className="@container grow border rounded-md 
                                bg-background max-h-full overflow-auto"
                            >
                                {children}
                            </div>
                        </div>
                    </Panel>
                    <PanelResizeHandle 
                        className="w-1.5 h-8 ml-1.5 my-auto 
                        bg-muted-foreground rounded-full" 
                    />
                    <Panel defaultSize={0} />
                </PanelGroup>
            </CardContent>
        </Card>
    )
}

Container Resizing

The container width is intrinsically tied to the viewport width. This means whenever the viewport width changes, the container will change along with it.

We're using the useResizeObserver hook to watch for any resize changes to the container. When a resize is observed, we update the containerSize state variable.

Additionally, we are disabling certain device previews based on the container size. If the container is too small to display a certain device, that button becomes disabled.

Demo Code Component

// @/components/demo-code.tsx

"use server"

import { Code, BrightProps} from "bright";

interface DemoCodeProps {
    title?: BrightProps["title"];
    lang?: BrightProps["lang"];
    lineNumbers?: BrightProps["lineNumbers"];
    className?: string;
    code: string;
}

Code.theme = {
    dark: 'dark-plus',
    light: 'light-plus',
    lightSelector: 'html.light',
}

export const DemoCode = ({
    title,
    lang,
    lineNumbers,
    className,
    code,
}: DemoCodeProps) => {

    return (
            <Code 
                title={title}
                lang={lang}
                lineNumbers={lineNumbers}
                className={className}
            >
                {code}
            </Code>
    )
}

Page Route

// @/app/page.tsx

import { Demo } from "@/components/demo";

const DemoPage = () => {
  return (
    <section className="w-full space-y-2">
      <Demo name="accordion-demo"/>
    </section>
  );
}

export default DemoPage;

Conclusion

There you have it! A fully featured component preview:

Check out the full source code on Github:

https://github.com/benorloff/code-preview

Acknowledgments