Using Sandpack & TailwindCSS to build a Code Playground Component

Using Sandpack & TailwindCSS to build a Code Playground Component

So, I've been wanting a good playground component to try and expand on some of the lessons I've learned from the last few years of working in React on both the web and native.

I've used Sandpack quite a bit in the past, so I'm gonna walk through building out a decent component using it and TailwindCSS for styling.

Installing Sandpack

First things first, let's install Sandpack.

yarn add @codesandbox/sandpack-react

Now, let's create a new component called CodePlayground and import the SandpackProvider and SandpackCodeEditor components.

src/components/CodePlayground.js
import React from 'react'
import {
  SandpackProvider,
  SandpackLayout,
  SandpackCodeEditor,
  SandpackPreview,
} from '@codesandbox/sandpack-react'

export function CodePlayground() {
  return (
    <SandpackProvider template="react">
      <SandpackLayout>
        <SandpackCodeEditor />
        <SandpackPreview />
      </SandpackLayout>
    </SandpackProvider>
  )
}

This should render a basic code editor, like this:

export default function App() {
  return <h1>Hello world</h1>
}

Pretty slick, eh? 🚀

Even just this would be pretty awesome for a lot of use cases, but let's see if we can make it even more usable for our purposes.

For this blog, I'd really like a few things:

  • A dark theme (to more closely match the other code samples on this site)
  • Top / Bottom layout for the editor and preview
  • A File explorer (for more complex examples)
  • A way to add custom dependencies (for things like react-router-dom)
  • The ability to run tests in Jest (I'll explain later)

So, with this stuff in mind, let's get started.

Adding a dark theme

This one is pretty simple. We just need to install @codesandbox/sandpack-themes and add the theme prop to our component.

yarn add @codesandbox/sandpack-themes

Then we can import the atomDark theme to our component, and add it as the theme prop to our SandpackProvider component.

src/components/CodePlayground.jsx
import React from 'react'
import {
  SandpackProvider,
  SandpackLayout,
  SandpackCodeEditor,
  SandpackPreview,
} from '@codesandbox/sandpack-react'
import { atomDark } from '@codesandbox/sandpack-themes'

export function CodePlayground() {
  return (
    <SandpackProvider template="react" theme={atomDark}>
      <SandpackLayout>
        <SandpackCodeEditor />
        <SandpackPreview />
      </SandpackLayout>
    </SandpackProvider>
  )
}

That should give you a nice dark theme, like this:

export default function App() {
  return <h1>Hello world</h1>
}

If you actually log out atomDark, it's not particularly complicated.

atomDark
{
  "colors": {
    "surface1": "#282c34",
    "surface2": "#21252b",
    "surface3": "#2c313c",
    "clickable": "#a8b1c2",
    "base": "#a8b1c2",
    "disabled": "#4d4d4d",
    "hover": "#e8effc",
    "accent": "#c678dd",
    "error": "#e06c75",
    "errorSurface": "#ffeceb"
  },
  "syntax": {
    "plain": "#a8b1c2",
    "comment": {
      "color": "#757575",
      "fontStyle": "italic"
    },
    "keyword": "#c678dd",
    "tag": "#e06c75",
    "punctuation": "#a8b1c2",
    "definition": "#62aeef",
    "property": "#d19a66",
    "static": "#a8b1c2",
    "string": "#98c379"
  },
  "font": {
    "body": "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\"",
    "mono": "\"Fira Mono\", \"DejaVu Sans Mono\", Menlo, Consolas, \"Liberation Mono\", Monaco, \"Lucida Console\", monospace",
    "size": "13px",
    "lineHeight": "20px"
  }
}

We can actually make some easy edits on this theme to make it match our TailwindCSS choices better.

First, we'll import Tailwinds colors. Then we can use the stock TailwindCSS colors to customize the atomDark theme to match our custom syntax highlighting theme.

src/components/CodePlayground.jsx
import React from 'react'
import {
  SandpackProvider,
  SandpackLayout,
  SandpackCodeEditor,
  SandpackPreview,
} from '@codesandbox/sandpack-react'
import { atomDark } from '@codesandbox/sandpack-themes'
import colors from 'tailwindcss/lib/public/colors'

const theme = {
  ...atomDark,
  colors: {
    ...atomDark.colors,
    surface1: colors.zinc[900],
  },
  syntax: {
    ...atomDark.syntax,
    plain: colors.zinc[100],
    keyword: colors.blue[300],
    definition: colors.pink[400],
    static: colors.zinc[100],
    string: colors.orange[300],
    property: colors.zinc[400],
    punctuation: colors.zinc[500],
    tag: colors.pink[400],
  },
}

export function CodePlayground() {
  return (
    <SandpackProvider template="react" theme={theme}>
      <SandpackLayout>
        <SandpackCodeEditor />
        <SandpackPreview />
      </SandpackLayout>
    </SandpackProvider>
  )
}

These changes should make our theme look more matchy-matchy 💄, like this:

export default function App() {
  return <h1>Hello world</h1>
}

Voila! 🎉

There's also a pretty excellent Sandpack theme builder available online, in case you want to go crazy!

Creating a custom Sandpack layout

Currently, Sandpack lays out the code editor and preview panel vertically on mobile, and horizontal on larger browsers.

I'd prefer this to be consistent on all screens, so let's update our playground to have a custom layout.

To do this, I'm going to wrap in some TailwindCSS divs and classes and set some new properties on Sandpack components.

src/components/CodePlayground.jsx
import React from 'react'
import {
  SandpackProvider,
  SandpackLayout,
  SandpackCodeEditor,
  SandpackPreview,
} from '@codesandbox/sandpack-react'
import { atomDark } from '@codesandbox/sandpack-themes'
import colors from 'tailwindcss/lib/public/colors'

const theme = {
  ...atomDark,
  colors: {
    ...atomDark.colors,
    surface1: colors.zinc[900],
    accent: colors.amber[300],
  },
  syntax: {
    ...atomDark.syntax,
    plain: colors.zinc[100],
    keyword: colors.blue[300],
    definition: colors.pink[400],
    static: colors.zinc[100],
    string: colors.orange[300],
    property: colors.zinc[400],
    punctuation: colors.zinc[500],
    tag: colors.pink[400],
  },
}

function TitleBar() {
  return (
    <div className="mb-0  flex justify-between items-center sm:rounded-t-lg bg-zinc-700 px-3 py-2">
      <span className="text-sm font-bold text-white">Title</span>
      <span className="">Actions</span>
    </div>
  )
}

function Console() {
  return (
    <div className="flex justify-between border border-zinc-700 bg-zinc-900 p-3">
      <div>
        <button>Preview</button>
        <button className="ml-2">Console</button>
      </div>
      <div>
        <button>Refresh</button>
      </div>
    </div>
  )
}

function Preview() {
  return (
    <>
      <div className="rounded-b-lg bg-zinc-900 p-4">
        <div className="overflow-hidden rounded bg-white p-1">
          <SandpackPreview showOpenInCodeSandbox={false} showRefreshButton={false} />
        </div>
      </div>
    </>
  )
}

export function CodePlayground() {
  return (
    <SandpackProvider template="react" theme={theme}>
      <SandpackLayout className="!block !rounded-none sm:!rounded-lg !-mx-4 sm:!mx-0">
        <TitleBar />
        <SandpackCodeEditor showTabs />
        <Console />
        <Preview />
      </SandpackLayout>
    </SandpackProvider>
  )
}

These updates should give you a good skeleton on all browser sizes, like this:

TitleActions
export default function App() {
  return <h1>Hello world</h1>
}

Wiring up the Sandpack title bar

Now, we want to add some features to the title bar, like a dynamic title prop, a reset button, and an open in CodeSandbox button.

To build this, we're gonna take advantage of Sandpack's built in hooks and the <UnstyledOpenInCodeSandboxButton /> component.

src/components/CodePlayground
import React from 'react'
import {
  SandpackProvider,
  SandpackLayout,
  SandpackCodeEditor,
  SandpackPreview,
  useSandpack,
  UnstyledOpenInCodeSandboxButton,
} from '@codesandbox/sandpack-react'
import { atomDark } from '@codesandbox/sandpack-themes'
import colors from 'tailwindcss/lib/public/colors'

const theme = {
  ...atomDark,
  colors: {
    ...atomDark.colors,
    surface1: colors.zinc[900],
    accent: colors.amber[300],
  },
  syntax: {
    ...atomDark.syntax,
    plain: colors.zinc[100],
    keyword: colors.blue[300],
    definition: colors.pink[400],
    static: colors.zinc[100],
    string: colors.orange[300],
    property: colors.zinc[400],
    punctuation: colors.zinc[500],
    tag: colors.pink[400],
  },
}

function ArrowTopRightIcon({ className = 'w-5 h-5' }) {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      fill="none"
      viewBox="0 0 24 24"
      strokeWidth={1.5}
      stroke="currentColor"
      className={className}
    >
      <path
        strokeLinecap="round"
        strokeLinejoin="round"
        d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
      />
    </svg>
  )
}

function ResetIcon({ className }) {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      fill="none"
      viewBox="0 0 24 24"
      strokeWidth={1.5}
      stroke="currentColor"
      className={className}
    >
      <path
        strokeLinecap="round"
        strokeLinejoin="round"
        d="M12 9.75L14.25 12m0 0l2.25 2.25M14.25 12l2.25-2.25M14.25 12L12 14.25m-2.58 4.92l-6.375-6.375a1.125 1.125 0 010-1.59L9.42 4.83c.211-.211.498-.33.796-.33H19.5a2.25 2.25 0 012.25 2.25v10.5a2.25 2.25 0 01-2.25 2.25h-9.284c-.298 0-.585-.119-.796-.33z"
      />
    </svg>
  )
}

function TitleBar({ title = 'Code Playground' }) {
  const { sandpack } = useSandpack()
  const { resetAllFiles } = sandpack

  return (
    <div className="mb-0  flex items-center justify-between bg-zinc-700 px-3 py-2 sm:rounded-t-lg">
      <span className="text-sm font-bold text-white">{title}</span>
      <span className="align-center flex">
        <button className="" onClick={() => resetAllFiles()}>
          <ResetIcon className="mr-4 h-5 w-5 text-zinc-300" />
        </button>
        <UnstyledOpenInCodeSandboxButton className="relative -top-[1px]">
          <ArrowTopRightIcon className="h-5 w-5 text-zinc-300" />
        </UnstyledOpenInCodeSandboxButton>
      </span>
    </div>
  )
}

function Console() {
  return (
    <div className="flex justify-between border border-zinc-700 bg-zinc-900 p-3">
      <div>
        <button>Preview</button>
        <button className="ml-2">Console</button>
      </div>
      <div>
        <button>Refresh</button>
      </div>
    </div>
  )
}

function Preview() {
  return (
    <>
      <div className="rounded-b-lg bg-zinc-900 p-4">
        <div className="overflow-hidden rounded bg-white p-1">
          <SandpackPreview
            showOpenInCodeSandbox={false}
            showRefreshButton={false}
          />
        </div>
      </div>
    </>
  )
}

export function CodePlayground() {
  return (
    <SandpackProvider template="react" theme={theme}>
      <SandpackLayout className="!-mx-4 !block !rounded-none sm:!mx-0 sm:!rounded-lg">
        <TitleBar />
        <SandpackCodeEditor showTabs />
        <Console />
        <Preview />
      </SandpackLayout>
    </SandpackProvider>
  )
}

These changes should give you a functioning title bar with a customizable title and ability to open the Playground on CodeSandbox:

Code Playground
export default function App() {
  return <h1>Hello world</h1>
}

Tada! ⚡️

Connecting the Preview & Console panels and wiring up Refresh

Now, we want to enable switching views between Preview and Console, as well as adding the ability to refresh either of those.

To build this, we're just going to use simple React useState hooks.

In the long term, I would probably refactor this to useContext, where I can pass my playground state down to any consuming component, but we'll keep it simple for today.

src/components/CodePlayground.jsx
import React, { useState, useEffect } from 'react'
import {
  SandpackProvider,
  SandpackLayout,
  SandpackCodeEditor,
  SandpackPreview,
  SandpackConsole,
  UnstyledOpenInCodeSandboxButton,
  useSandpack,
  useSandpackNavigation,
} from '@codesandbox/sandpack-react'
import { atomDark } from '@codesandbox/sandpack-themes'
import colors from 'tailwindcss/lib/public/colors'
import clsx from 'clsx'

const theme = {
  ...atomDark,
  colors: {
    ...atomDark.colors,
    surface1: colors.zinc[900],
    accent: colors.amber[300],
  },
  syntax: {
    ...atomDark.syntax,
    plain: colors.zinc[100],
    keyword: colors.blue[300],
    definition: colors.pink[400],
    static: colors.zinc[100],
    string: colors.orange[300],
    property: colors.zinc[400],
    punctuation: colors.zinc[500],
    tag: colors.pink[400],
  },
}

function RefreshIcon({ className = 'w-5 h-5' }) {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      fill="none"
      viewBox="0 0 24 24"
      strokeWidth={1.5}
      stroke="currentColor"
      className={className}
    >
      <path
        strokeLinecap="round"
        strokeLinejoin="round"
        d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
      />
    </svg>
  )
}

function ArrowTopRightIcon({ className = 'w-5 h-5' }) {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      fill="none"
      viewBox="0 0 24 24"
      strokeWidth={1.5}
      stroke="currentColor"
      className={className}
    >
      <path
        strokeLinecap="round"
        strokeLinejoin="round"
        d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
      />
    </svg>
  )
}

function ResetIcon({ className }) {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      fill="none"
      viewBox="0 0 24 24"
      strokeWidth={1.5}
      stroke="currentColor"
      className={className}
    >
      <path
        strokeLinecap="round"
        strokeLinejoin="round"
        d="M12 9.75L14.25 12m0 0l2.25 2.25M14.25 12l2.25-2.25M14.25 12L12 14.25m-2.58 4.92l-6.375-6.375a1.125 1.125 0 010-1.59L9.42 4.83c.211-.211.498-.33.796-.33H19.5a2.25 2.25 0 012.25 2.25v10.5a2.25 2.25 0 01-2.25 2.25h-9.284c-.298 0-.585-.119-.796-.33z"
      />
    </svg>
  )
}

function TitleBar({ title = 'Code Playground' }) {
  const { sandpack } = useSandpack()
  const { resetAllFiles } = sandpack

  return (
    <div className="mb-0  flex items-center justify-between bg-zinc-700 px-3 py-2 sm:rounded-t-lg">
      <span className="text-sm font-bold text-white">{title}</span>
      <span className="align-center flex">
        <button className="" onClick={() => resetAllFiles()}>
          <ResetIcon className="mr-4 h-5 w-5 text-zinc-300" />
        </button>
        <UnstyledOpenInCodeSandboxButton className="relative -top-[1px]">
          <ArrowTopRightIcon className="h-5 w-5 text-zinc-300" />
        </UnstyledOpenInCodeSandboxButton>
      </span>
    </div>
  )
}

function Console({ isPreview, setMode }) {
  const [reloading, setReloading] = useState(false)
  const { sandpack, listen } = useSandpack()
  const { refresh } = useSandpackNavigation()
  const activeClass = 'border-b border-amber-500'

  useEffect(() => {
    // listens for any message dispatched between sandpack and the bundler
    const stopListening = listen((msg) => {
      if (msg?.status === 'idle') {
        setReloading(false)
      }
    })

    return () => {
      // unsubscribe
      stopListening()
    }
  }, [listen])

  return (
    <div className="flex items-center justify-between border border-zinc-700 bg-zinc-900 px-3">
      <div>
        <button
          className={clsx('mr-6 py-3', isPreview ? activeClass : null)}
          onClick={() => setMode('result')}
        >
          Preview
        </button>
        <button
          className={clsx('py-3', !isPreview ? activeClass : null)}
          onClick={() => setMode('console')}
        >
          Console
        </button>
      </div>
      <div>
        <button
          onClick={() => {
            setReloading(true)
            refresh()
          }}
          disabled={sandpack?.status === 'idle'}
        >
          <RefreshIcon
            className={clsx(
              'h-5 w-5 text-zinc-400',
              reloading && 'animate-spin',
              sandpack?.status === 'idle' && 'text-zinc-600'
            )}
          />
        </button>
      </div>
    </div>
  )
}

function Preview({ isPreview }) {
  return (
    <>
      <div className="rounded-b-lg bg-zinc-900 p-4">
        <div
          className={clsx(
            !isPreview ? 'hidden' : 'block',
            'overflow-hidden rounded bg-white p-1'
          )}
        >
          <SandpackPreview
            showOpenInCodeSandbox={false}
            showRefreshButton={false}
          />
        </div>

        <div
          className={clsx(
            isPreview ? 'hidden' : 'block',
            'min-h-[160px] overflow-hidden rounded'
          )}
        >
          <SandpackConsole
            standalone
            resetOnPreviewRestart
            showHeader={false}
          />
        </div>
      </div>
    </>
  )
}

export function CodePlayground() {
  const [mode, setMode] = useState('result')
  const isPreview = mode === 'result'

  const previewProps = {
    mode,
    setMode,
    isPreview,
  }

  return (
    <SandpackProvider template="react" theme={theme}>
      <SandpackLayout className="!-mx-4 !block !rounded-none sm:!mx-0 sm:!rounded-lg">
        <TitleBar />
        <SandpackCodeEditor showTabs />
        <Console {...previewProps} />
        <Preview {...previewProps} />
      </SandpackLayout>
    </SandpackProvider>
  )
}

Whew 😅. That was a lot of additional code, so let's break down what I did.

First, I import useState and useEffect from react because I knew we would be using them.

I also imported the SandpackConsole component, useSandpackNavigation hooks and clsx for className management.

import React, { useState, useEffect } from 'react'
import {
  SandpackProvider,
  SandpackLayout,
  SandpackCodeEditor,
  SandpackPreview,
  SandpackConsole,
  UnstyledOpenInCodeSandboxButton,
  useSandpack,
  useSandpackNavigation,
} from '@codesandbox/sandpack-react'
import { atomDark } from '@codesandbox/sandpack-themes'
import colors from 'tailwindcss/lib/public/colors'
import clsx from 'clsx'

Next, I created a quick RefreshIcon component. I did this by going to HeroIcons, searching for "refresh" and copying the JSX.

Then I added a className prop to the icon component.

function RefreshIcon({ className = 'w-5 h-5' }) {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      fill="none"
      viewBox="0 0 24 24"
      strokeWidth={1.5}
      stroke="currentColor"
      className={className}
    >
      <path
        strokeLinecap="round"
        strokeLinejoin="round"
        d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
      />
    </svg>
  )
}

Next, I updated my Preview sub component to switch between showing a <SandpackPreview /> or a <SandpackConsole /> component based on a simple piece of state.

I'm using CSS to control the display, because under the hood, Sandpack does a fair bit of work strapping up each of those components, and I don't really want to be adding and removing them from the DOM over and over again.

function Preview({ isPreview }) {
  return (
    <>
      <div className="rounded-b-lg bg-zinc-900 p-4">
        <div
          className={clsx(
            !isPreview ? 'hidden' : 'block',
            'overflow-hidden rounded bg-white p-1'
          )}
        >
          <SandpackPreview
            showOpenInCodeSandbox={false}
            showRefreshButton={false}
          />
        </div>

        <div
          className={clsx(
            isPreview ? 'hidden' : 'block',
            'min-h-[160px] overflow-hidden rounded'
          )}
        >
          <SandpackConsole
            standalone
            resetOnPreviewRestart
            showHeader={false}
          />
        </div>
      </div>
    </>
  )
}

After that, I updated my Playground component to support a mode with the useState hook. I then pass those props down to my <Console /> and <Preview /> components.

Note: Console is probably a silly name for this particular component. I named it before I realized that the SandpackConsole and SandpackPreview components would be held inside my Preview component. A better name would probably be something like PreviewActions or something.
export function CodePlayground() {
  const [mode, setMode] = useState('result')
  const isPreview = mode === 'result'

  const previewProps = {
    mode,
    setMode,
    isPreview,
  }

  return (
    <SandpackProvider template="react" theme={theme}>
      <SandpackLayout className="!-mx-4 !block !rounded-none sm:!mx-0 sm:!rounded-lg">
        <TitleBar />
        <SandpackCodeEditor showTabs />
        <Console {...previewProps} />
        <Preview {...previewProps} />
      </SandpackLayout>
    </SandpackProvider>
  )
}

Lastly, I updated the (poorly named) <Console /> component to actually wire up the buttons, and enable switching between modes.

Under the hood, Sandpack uses a redux like store to dispatch events.

In order to allow the refresh button to spin while the preview is reloading, we can use a useEffect combined with useSandpack's exported listen method to listen for the idle status message.

This let's us know the editor is finished loading & transpiling, and our refresh button can stop spinning.

function Console({ isPreview, setMode }) {
  const [reloading, setReloading] = useState(false)
  const { sandpack, listen } = useSandpack()
  const { refresh } = useSandpackNavigation()
  const activeClass = 'border-b border-amber-500'

  useEffect(() => {
    // listens for any message dispatched between sandpack and the bundler
    const stopListening = listen((msg) => {
      if (msg?.status === 'idle') {
        setReloading(false)
      }
    })

    return () => {
      // unsubscribe
      stopListening()
    }
  }, [listen])

  return (
    <div className="flex items-center justify-between border border-zinc-700 bg-zinc-900 px-3">
      <div>
        <button
          className={clsx('mr-6 py-3', isPreview ? activeClass : null)}
          onClick={() => setMode('result')}
        >
          Preview
        </button>
        <button
          className={clsx('py-3', !isPreview ? activeClass : null)}
          onClick={() => setMode('console')}
        >
          Console
        </button>
      </div>
      <div>
        <button
          onClick={() => {
            setReloading(true)
            refresh()
          }}
          disabled={sandpack?.status === 'idle'}
        >
          <RefreshIcon
            className={clsx(
              'h-5 w-5 text-zinc-400',
              reloading && 'animate-spin',
              sandpack?.status === 'idle' && 'text-zinc-600'
            )}
          />
        </button>
      </div>
    </div>
  )
}

And with that, we have a pretty decent working Sandpack CodePlayground:

Code Playground
import React, { useState } from 'react'
import './styles.css'

export default function App() {
  const [count, setCount] = useState(0)
  return (
    <>
      <div>
        {count}
      </div>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </>
  )
}

console.warn('I am a warning!');

Boom! 🦄💪🎉✨

Next steps

Since this article is getting pretty long, I'm going to split some of the next features into another article.

Happy coding!

If you enjoyed this article, please consider following me on Twitter

Subscribe to the Newsletter

Subscribe for exclusive tips, strategies, and resources to launch, grow, & build your SaaS team.

Share this article on: