Preview Block

A framed stage for live demos and their code

Preview Block frames a live demo: a toolbar with a light/dark switch, a padded stage, and optionally the demo's source attached underneath, all in one outline. Every example on this site is one. The stage has its own Theme, so switching a preview to dark leaves the page alone.

Installation

Install the dependencies.

npm install @kushagradhawan/kookie-ui @hugeicons/react @hugeicons/core-free-icons shiki

This block imports Code Block. Copy that into components/blocks first.

Copy these files into components/blocks/preview-block. Keep the folder names, since blocks import each other by relative path.

components/blocks/preview-block/preview-block.tsx
'use client';

import * as React from 'react';
import { SegmentedControl, Theme } from '@kushagradhawan/kookie-ui';
import { HugeiconsIcon } from '@hugeicons/react';
import { Moon02Icon, Sun01Icon } from '@hugeicons/core-free-icons';
import { CodeBlock, type CodeBlockProps } from '../code-block/code-block';
import './preview-block.css';

type Appearance = 'light' | 'dark';

export interface PreviewBlockProps {
  children: React.ReactNode;
  /** Stage background: a preset pattern, or your own CSS. @default "dots" */
  background?: 'none' | 'dots' | 'grid' | React.CSSProperties;
  /** Spacing of the dots or grid lines, in pixels. @default 20 */
  patternSize?: number;
  /** Pattern color. Defaults to `--gray-a5` for dots and `--gray-a3` for grid. */
  patternColor?: string;
  /** Fixed stage height, e.g. `"24rem"` for demos that open menus. Without it the stage fits its content. */
  height?: string;
  /** Starting appearance. Defaults to the page's. */
  appearance?: Appearance;
  /** Show the light/dark switch. @default true */
  showThemeToggle?: boolean;
  /** Label in the toolbar, e.g. the example's name. */
  label?: React.ReactNode;
  /** @default "sans" */
  fontFamily?: 'sans' | 'mono' | 'serif';
  /** Source shown under the stage, in the same frame. */
  code?: string;
  /** Language for `code`. @default "tsx" */
  language?: string;
  /** Extra `CodeBlock` options for `code`. */
  codeProps?: Omit<CodeBlockProps, 'code' | 'language' | 'children'>;
  className?: string;
}

function getBackgroundStyle(
  background: PreviewBlockProps['background'],
  patternSize: number,
  patternColor: string | undefined,
): React.CSSProperties | undefined {
  if (typeof background === 'object') return background;

  const size = `${patternSize}px ${patternSize}px`;
  if (background === 'dots') {
    const color = patternColor ?? 'var(--gray-a5)';
    return { backgroundImage: `radial-gradient(circle, ${color} 1px, transparent 1px)`, backgroundSize: size, backgroundPosition: 'center' };
  }
  if (background === 'grid') {
    const color = patternColor ?? 'var(--gray-a3)';
    return {
      backgroundImage: `linear-gradient(${color} 1px, transparent 1px), linear-gradient(90deg, ${color} 1px, transparent 1px)`,
      backgroundSize: size,
      backgroundPosition: 'center',
    };
  }
  return undefined;
}

/**
 * A framed stage for a live demo: a toolbar with a light/dark switch, a padded stage, and
 * optionally the demo's source attached underneath.
 */
export function PreviewBlock({
  children,
  background = 'dots',
  patternSize = 20,
  patternColor,
  height,
  appearance,
  showThemeToggle = true,
  label,
  fontFamily = 'sans',
  code,
  language = 'tsx',
  codeProps,
  className,
}: PreviewBlockProps) {
  const [chosenAppearance, setChosenAppearance] = React.useState<Appearance | undefined>(appearance);
  const hasToolbar = showThemeToggle || label !== undefined;

  return (
    <div className={className ? `preview-block ${className}` : 'preview-block'}>
      {hasToolbar && (
        <div className="preview-block-toolbar">
          {label !== undefined && <span className="preview-block-label">{label}</span>}
          {showThemeToggle && (
            <SegmentedControl.Root
              size="2"
              value={chosenAppearance ?? 'light'}
              onValueChange={(value) => setChosenAppearance(value === 'dark' ? 'dark' : 'light')}
              className="preview-block-theme-toggle"
            >
              <SegmentedControl.Item value="light" iconOnly aria-label="Light preview">
                <HugeiconsIcon icon={Sun01Icon} strokeWidth={1.75} />
              </SegmentedControl.Item>
              <SegmentedControl.Item value="dark" iconOnly aria-label="Dark preview">
                <HugeiconsIcon icon={Moon02Icon} strokeWidth={1.75} />
              </SegmentedControl.Item>
            </SegmentedControl.Root>
          )}
        </div>
      )}

      {/* Its own Theme, so switching the preview leaves the page alone. */}
      <Theme appearance={chosenAppearance ?? 'inherit'} fontFamily={fontFamily} hasBackground className="preview-block-theme">
        <div className="preview-block-stage" style={{ ...getBackgroundStyle(background, patternSize, patternColor), height }}>
          {children}
        </div>
      </Theme>

      {code !== undefined && <CodeBlock {...codeProps} code={code} language={language} />}
    </div>
  );
}
components/blocks/preview-block/preview-block.css
/*
 * Preview frame. Shares its outline with `.code-block`, so a preview and its code read as one
 * object. Override these on `.preview-block` to restyle.
 */
.preview-block {
  --preview-block-radius: var(--radius-surface-4);
  --preview-block-border: var(--gray-a4);
  --preview-block-padding: var(--space-6);
  --preview-block-min-height: 10rem;

  display: flex;
  flex-direction: column;
  min-width: 0;
  border: 1px solid var(--preview-block-border);
  border-radius: var(--preview-block-radius);
  overflow: hidden;
}

.preview-block-toolbar {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  min-height: var(--space-7);
  padding: 0 var(--space-2) 0 var(--space-4);
  background-color: var(--gray-a2);
  border-bottom: 1px solid var(--preview-block-border);
}

.preview-block-label {
  color: var(--gray-11);
  font-size: var(--font-size-2);
  line-height: var(--line-height-2);
}

.preview-block-theme-toggle {
  margin-left: auto;
}

/* The nested Theme paints the stage in the preview's own appearance. */
.preview-block-theme {
  min-height: 0;
}

.preview-block-stage {
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  min-height: var(--preview-block-min-height);
  padding: var(--preview-block-padding);
  overflow: auto;
}

/* Attached source: one frame, divided by a rule. */
.preview-block > .code-block {
  border: 0;
  border-top: 1px solid var(--preview-block-border);
  border-radius: 0;
}

@media (max-width: 520px) {
  .preview-block {
    --preview-block-padding: var(--space-4);
  }
}

Usage

import { PreviewBlock } from '@/components/blocks/preview-block/preview-block';
 
<PreviewBlock code={`<Button variant="classic">Save</Button>`}>
  <Button variant="classic">Save</Button>
</PreviewBlock>

In MDX you can also follow a PreviewBlock with a code fence. Markdown joins the two into one frame, as on this page.

Props

PropTypeDefaultDescription
codestring-Source shown under the stage, in the same frame.
languagestring'tsx'Language for code.
codePropsCodeBlockProps-Extra Code Block options for code, such as file.
background'none' | 'dots' | 'grid' | CSSProperties'dots'A preset pattern, or your own styles for the stage.
patternSizenumber20Spacing of the dots or grid lines, in pixels.
patternColorstring--gray-a5 (dots), --gray-a3 (grid)Pattern color.
heightstring-Fixed stage height, for demos that fill their container. Without it the stage fits its content, at least 10rem tall.
appearance'light' | 'dark'Page'sStarting appearance of the stage.
showThemeTogglebooleantrueShow the light/dark switch.
labelReactNode-Text on the left of the toolbar.
fontFamily'sans' | 'mono' | 'serif''sans'Font for the stage.
classNamestring-Class on the frame.

The toolbar only renders when there is a switch or a label.

Styling

PropertyDefault
--preview-block-radiusvar(--radius-surface-4)
--preview-block-bordervar(--gray-a4)
--preview-block-paddingvar(--space-6), var(--space-4) on small screens
--preview-block-min-height10rem

Examples

Grid Stage

A grid suits layout demos, where alignment matters.

Storage card
Storage18.4 GB of 50 GB used
<PreviewBlock background="grid" label="Storage card" code={source}>
  <Card variant="classic" size="2">
    <Flex direction="column" gap="1" width="240px">
      <Text size="2" weight="medium">Storage</Text>
      <Text size="2" emphasis="medium">18.4 GB of 50 GB used</Text>
    </Flex>
  </Card>
</PreviewBlock>

Fixed Dark Stage

Pin the stage to dark and hide the switch when a demo only makes sense one way.

LiveDeployed 2 minutes ago
<PreviewBlock appearance="dark" showThemeToggle={false} code={source}>
  <Flex gap="2" align="center">
    <Badge color="green" variant="soft">Live</Badge>
    <Text size="2">Deployed 2 minutes ago</Text>
  </Flex>
</PreviewBlock>

Custom Background

Pass styles instead of a preset for gradients or images.

<PreviewBlock
  showThemeToggle={false}
  background={{
    backgroundImage: 'linear-gradient(135deg, var(--iris-4), var(--pink-4))',
  }}
  code={source}
>
  <Button variant="soft" material="translucent">Action</Button>
</PreviewBlock>

Accessibility

The light/dark switch is a segmented control whose options are labelled "Light preview" and "Dark preview". The demo keeps its own semantics.

© 2026 Kushagra Dhawan. Licensed under MIT. GitHub.

Theme

Accent color

Gray color

Appearance

Radius

Scaling

Panel background