technical

Managing Interactive Demos in MDX

Building a playground to handle demos for interactive articles in Astro MDX.
Oct 18, 2024
9 min read

Its been around two months since I launched this site. Design and content are coming along nicely. I have been able to write a few articles and I am quite happy with the way they have turned out. A lot of tiny features were recommended by friends and the last few posts were also well reviewed by them. Since my articles contain a lot of demos with controls, its starting to get little difficult in managing them. Its not too late and I think this is the right time to think of a better way to manage them.

The Problem

This is what I call `Stage` in my project

The above container is what I call a Stage. When I create any demo, I place them inside this Stage. And since they are interactive, I add a few controls to them, so that users can change certain settings and see the preview of those changes in real time. This is a great way to learn and understand the concepts. The problems that I face are as follows:

  • I dont have a set of plug and play controls that I can instantly use.
  • I have to create the controls and then hook them up with the main demo.
  • Layout of the stage is not consistent across the site.
  • Part of the code gets duplicated because I feel lazy sometimes.
  • When writing an article, majority of my time should be spent on the content and not creating the Stage.

I know a few sites whose pattern is similar to mine but I dont know how do they manage them. I searched for some libraries that could help me with this but I couldnt find any. So I thought I will try something out.

What I want

I write articles in MDX. I want the text to be more visible when I write, so each demo should not pollute the content. This means something like this is ideal.

## Some heading
This is my article. I am writing about something. I have a demo here.

<Stage title="Title of this demo" footer="..." >
  <Demo/>
</Stage>

I continue writing my article.

The reason I prefer keeping the <Stage> in MDX and not inside the <Demo/> is because I can change couple of attributes when I am reviewing and I dont have to step inside the demo component. This is still clean. But sometimes, the demo can be just be an html element. Something like a button. When you click, some effect happens. It is quite small to create a new component for it. So I want to be able to write something like this.

## Some heading
This is my article. I am writing about something. I have a demo here.

<Stage toggleStates={1}>  // 1 means only one toggle state
  {({ toggleState }) => (
      <button onClick={toggleState[0].toggle} 
        className={clsx('btn-demo',{
          'bg-red-500': !toggleState[0].active
          'bg-green-500': toggleState[0].active
          })}>Click Me</button>
  )}
</Stage>

I continue writing my article.

This is not clean, but for one-off demos, I can accept it. The Stage component will render the controls and pass the state of the controls to the children. The children can then use this state to control the demo. Render Props are quite powerful. And I am sure I will find more use cases for them in future.

Secondly I also need to think of layouts to place the controls and the demo. The controls should be grouped together. I will need appropriate Row and Column components to manage the layout. These will be a part of <Demo/> component. But if I want I can also use them in MDX.

Thats the basic idea.

Limitations

I am using Astro, React and Astro-MDX as my stack. I implemented a small version of the Stage component and tried to use it in MDX with render props. And it did not work. So I started debugging this problem. I removed render props(it clearly didn’t work). I wrote a Bold component that wraps the children in a <strong> tag. And I want to print the children. See example below.

I have this markup in Astro MDX.

<Bold client:load>
  <span>Hello World</span>
</Bold>

And this is my Bold component.

export function Bold({ children }) {
  console.log({ children });
  return <strong>children</strong>;
}

This is what I got.

{
  children: {
    '$$typeof': Symbol(react.element),
    type: [Function: StaticHtml] {
      shouldComponentUpdate: [Function (anonymous)]
    },
    key: null,
    ref: null,
    props: { hydrate: true, value: [SlotString [HTMLString]] },
    _owner: null,
    _store: {}
  }
}

I found something interesting. If you see the value prop, its wrapped inside a SlotString which is a special type of string that Astro uses to hydrate the content. And the content is a string containing html. Below is the function that Astro uses. Link to source

Astro Slot Component

If I do the same in React, I get this. It logs the original children.

{
  children: {
    '$$typeof': Symbol(react.element), 
    type: 'span',
    key: null,
    ref: null,
    props: { children: 'Hello World' },
    _owner: null,
    _store: {}
  }
}

Instead of passing React elements as slots, Astro passes plain HTML elements, which gets processed as string.

This way of rendering clarifies the limitations I faced. Render props rely on React’s runtime. When used in MDX files in Astro, the static generation process can’t handle this dynamic behavior fully, because MDX is statically rendered at compile time. In other words, render props introduce dynamic behavior that Astro’s compiler can’t statically evaluate.

The benefit that Astro provides with slots is powerful. All of my layouts are working with slots and I wont sacrifice that. I decided to continue with the <Demo/> component and figure out a way to map controls to the demo.

Got something working fine

I followed the below approach.

  • Always create a new component for the demo.
  • Build stage related controls as hooks.
  • Create <Row> and <Column> components to manage layout.
  • Compose the Layout and keep them as resuable Templates.

I created a useCheckbox hook which returns the checkbox component and the state of the checkbox.

const { Checkbox, checked: checkboxValue } = useCheckbox({
  id: 'slow',
  label: '4x slow',
});

I can use this hook in my demo like this.

const Demo = () => {
  const { Checkbox, checked: checkboxValue } = useCheckbox({
    id: 'slow',
    label: '4x slow',
  });
  return (
    <Row>
      <Column>
        <Checkbox title="Watch in slow motion:" />
      </Column>
      <Column>
        <DemoPlayground checkbox={checkboxValue}/>
      </Column>
    </Row>
  );
};

Similarly, I created other controls like useInputRange, useInputCounter, useRadio, useButton and useButtonGroup.

Info

I am demonstrating all the controls in a single stage but none of my demos will have that many. I am simply showing the capabilities of the stage component.

Click [1-4] buttons and see different layouts.
Demo Playground

Checkbox

Option

1

Button

-

Range

50

Button Group

l1

Counter

0

Footer note

You will notice few things here if you play around with the button group (1-4).

  • I am able to group the controls in specific sections.
  • I can write labels for each control.
  • The demo playground gets updated dynamically as I change the controls.
  • The demo playground can have different sizes.
  • The layout is responsive.
  • The FPS counter is not optional, its inbuilt in the stage.

The Row and Column can also accept props. I could have passed tailwind classes, but that would not be consistent across the site. So I created a few deterministic props that I can pass to these components. Internally, they are mapped to tailwind classes.

interface ColumnProps {
  className?: string;
  children: React.ReactNode;
  onClick?: React.MouseEventHandler<HTMLDivElement>;
  gap?: 'sm' | 'md' | 'lg';
  align?: 'start' | 'center' | 'end' | 'space-between' | 'space-around';
  justify?: 'center' | 'left' | 'right' | 'space-between' | 'space-around';
  border?: boolean;
}

interface RowProps {
  children: React.ReactNode;
  align?: 'left' | 'right' | 'center' | 'space-between' | 'space-around';
  onClick?: React.MouseEventHandler<HTMLDivElement>;
  className?: string;
  gap?: 'sm' | 'md' | 'lg';
  border?: boolean;
}

Want to know when I publish new content?

Enter your email to join my free newsletter.

I wrap the Stage component around this demo. This is how the Stage component looks like.

export const Stage = ({ children, className, note, title }: StageProps) => {
  return (
    <>
      {title && (
        <strong className="inline-flex mb-1 font-inter text-sm mt-2 text-text-tertiary">
          {title}
        </strong>
      )}
      <Wrapper className={className}>
        <Fps />
        <Column>
          <Row>{children}</Row>
          <Row align="center">
            {note && (
              <div className="text-gray-500 text-center text-xs mt-6 md:font-mono">
                {note}
              </div>
            )}
          </Row>
        </Column>
      </Wrapper>
    </>
  );
};
Tip

You can use client:visible to show a component when its in viewport. This is a feature of Astro. I am using it to show the demo only when its in viewport. Demos can be big and I dont want the page load get affected.

This is not final and I will keep tweeking them as I go along. I am not looking for this to be perfect. I just want it to be usable and configurable. I am fine giving it time to evolve.

Where is the source code?

I dont want to share something that is not complete. This is my first iteration and I will keep looking out for ideas to improve. If it turns out well, I will create a npm package out of it and share it with the community. In the mean time, if you have any suggestions, feel free to provide feedbacks. If you write interactive articles, how do you manage them? You can reach out to me on Twitter.

Did you like the post?