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: