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.
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.
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.
{
"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.
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.
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:
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.
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:
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.
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 theSandpackConsole
andSandpackPreview
components would be held inside myPreview
component. A better name would probably be something likePreviewActions
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:
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