Skip to main content

Resizable Preview

Ladle helps in preview of components. If you are building a component library and you want help showing previews of components, Ladle can help you.

Configure ShadCN

Install packages

We are going to use react-resizable-panels and some styling from shadcn

npm i tailwindcss-animate clsx tailwind-merge react-resizable-panels lucide-react

Add CSS Variables to tailwind.CSS

src/styles/tailwind.css
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 47.4% 11.2%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--card: 0 0% 100%;
--card-foreground: 222.2 47.4% 11.2%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 100% 50%;
--destructive-foreground: 210 40% 98%;
--ring: 215 20.2% 65.1%;
--radius: 0.5rem;
}

.dark {
--background: 224 71% 4%;
--foreground: 213 31% 91%;
--muted: 223 47% 11%;
--muted-foreground: 215.4 16.3% 56.9%;
--accent: 216 34% 17%;
--accent-foreground: 210 40% 98%;
--popover: 224 71% 4%;
--popover-foreground: 215 20.2% 65.1%;
--border: 216 34% 17%;
--input: 216 34% 17%;
--card: 224 71% 4%;
--card-foreground: 213 31% 91%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 1.2%;
--secondary: 222.2 47.4% 11.2%;
--secondary-foreground: 210 40% 98%;
--destructive: 0 63% 31%;
--destructive-foreground: 210 40% 98%;
--ring: 216 34% 17%;
}
}

Add ShanCN Variables to tailwind.config

tailwind.config.ts
import { type Config } from "tailwindcss";
import {
scopedPreflightStyles,
isolateInsideOfContainer,
} from "tailwindcss-scoped-preflight";

/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.html", "./src/**/*.js", "./src/**/*.tsx", "./*.ts"],
darkMode: ["class", '[data-theme="dark"]'],
important: true,

theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: `var(--radius)`,
md: `calc(var(--radius) - 2px)`,
sm: "calc(var(--radius) - 4px)",
},
},
},

plugins: [
require("tailwindcss-animate"),
require("@tailwindcss/typography"),
scopedPreflightStyles({
isolationStrategy: isolateInsideOfContainer(".twp", {
except: ".no-twp",
}),
}),
],
} satisfies Config;

Add ResizableLadlePreview.tsx

src/components/ResizableLadlePreview.tsx
"use client";

import { GripVertical } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { ImperativePanelHandle } from "react-resizable-panels";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import * as React from "react";
import { useColorMode } from "@docusaurus/theme-common";

export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
);

const ResizablePanel = ResizablePrimitive.Panel;

const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);

const getLadleUrl = (story) => {
const isDevelopment = process.env.NODE_ENV === "development";
if (isDevelopment) {
return `http://localhost:61000/ladle?story=${story}&mode=preview`;
}
return `/ladle?mode=preview&story=${story}`;
};

export default function ResizableLadlePreview({ story, height }) {
const resizablePanelRef = React.useRef<ImperativePanelHandle>(null);

const { colorMode, setColorMode } = useColorMode();

React.useEffect(() => {
const updateIframeTheme = (theme) => {
const iframe = document.querySelector("iframe");
if (iframe && iframe.contentDocument) {
iframe.contentDocument.documentElement.setAttribute(
"data-theme",
theme
);
}
};

updateIframeTheme(colorMode);
}, [colorMode]);

return (
<div className="group-data-[view=code]/block-view-wrapper:hidden md:h-[--height] twp">
<div className="grid w-full gap-4">
<ResizablePanelGroup direction="horizontal" className="relative z-10">
<ResizablePanel
ref={resizablePanelRef}
className="relative aspect-[4/2.5] rounded-xl border bg-background md:aspect-auto"
defaultSize={100}
minSize={30}
>
<iframe
allow="cross-origin-isolated"
src={getLadleUrl(story)}
height={height || 930}
className="relative z-20 hidden w-full bg-background md:block"
/>
</ResizablePanel>
<ResizableHandle className="relative hidden w-3 bg-transparent p-0 after:absolute after:right-0 after:top-1/2 after:h-8 after:w-[6px] after:-translate-y-1/2 after:translate-x-[-1px] after:rounded-full after:bg-border after:transition-all after:hover:h-10 md:block" />
<ResizablePanel defaultSize={0} minSize={0} />
</ResizablePanelGroup>
</div>
</div>
);
}

Test Preview

Add Hello World Story

src/stories/hello.stories.tsx
export const World = () => (
<p className="bg-red-200 md:bg-blue-200 m-6 p-6 dark:md:bg-green-200">
Hello World
</p>
);

Run Ladle

For following to work during development. Keep both commands running:

npm run start
npm run ladle:serve

Example Story with Preview

In any .mdx file of docusaurus add following The components loads ladle preview of a story in an iframe which is resizable.

import ResizableLadlePreview from "@site/src/components/ResizableLadlePreview";
<ResizableLadlePreview story="hello--world" height={200} />;

It should show up like following:

Unrealistic Example

Realistic Example