From b3200b3e43241ff21058e0f39b4246fc9a0f349b Mon Sep 17 00:00:00 2001 From: Saurabh Date: Sat, 27 Jun 2026 08:16:51 +0530 Subject: [PATCH 01/10] feat: motion playground Standalone /playground route to learn motion properties hands-on. Spring, tween (with a draggable bezier editor) and stagger, each with a labelled preview, per-control vocab hints, quick-start presets and live copy-paste code. Self-contained modules under components/app/playground; not part of the component registry. --- app/playground/page.tsx | 12 ++ components/app/mobile-nav.tsx | 16 +- components/app/playground/code-panel.tsx | 28 +++ components/app/playground/controls.tsx | 97 +++++++++ components/app/playground/core.ts | 74 +++++++ components/app/playground/curve-editor.tsx | 99 ++++++++++ components/app/playground/items/index.ts | 22 +++ components/app/playground/items/spring.tsx | 89 +++++++++ components/app/playground/items/stagger.tsx | 137 +++++++++++++ .../app/playground/items/travel-preview.tsx | 51 +++++ components/app/playground/items/tween.tsx | 75 +++++++ components/app/playground/playground.tsx | 186 ++++++++++++++++++ components/app/site-header.tsx | 23 ++- 13 files changed, 901 insertions(+), 8 deletions(-) create mode 100644 app/playground/page.tsx create mode 100644 components/app/playground/code-panel.tsx create mode 100644 components/app/playground/controls.tsx create mode 100644 components/app/playground/core.ts create mode 100644 components/app/playground/curve-editor.tsx create mode 100644 components/app/playground/items/index.ts create mode 100644 components/app/playground/items/spring.tsx create mode 100644 components/app/playground/items/stagger.tsx create mode 100644 components/app/playground/items/travel-preview.tsx create mode 100644 components/app/playground/items/tween.tsx create mode 100644 components/app/playground/playground.tsx diff --git a/app/playground/page.tsx b/app/playground/page.tsx new file mode 100644 index 0000000..92a9b73 --- /dev/null +++ b/app/playground/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; +import { Playground } from "@/components/app/playground/playground"; + +export const metadata: Metadata = { + title: "Playground", + description: + "Tweak spring, tween and stagger properties, watch them play, and copy the motion code. Built on beUI's motion tokens.", +}; + +export default function PlaygroundPage() { + return ; +} diff --git a/components/app/mobile-nav.tsx b/components/app/mobile-nav.tsx index 34ea757..c2f6c7f 100644 --- a/components/app/mobile-nav.tsx +++ b/components/app/mobile-nav.tsx @@ -1,12 +1,12 @@ "use client"; -import Link from "next/link"; import { Menu } from "lucide-react"; +import Link from "next/link"; import { usePathname } from "next/navigation"; import { useEffect, useState } from "react"; +import { SidebarNav } from "@/components/app/site-sidebar"; import { BottomSheet } from "@/components/motion/bottom-sheet"; import { Button } from "@/components/motion/button"; -import { SidebarNav } from "@/components/app/site-sidebar"; import { cn } from "@/lib/utils"; /** Mobile nav: a header hamburger that opens the sidebar list in beUI's own bottom sheet. */ @@ -64,6 +64,18 @@ export function MobileNav() { > Blocks + setOpen(false)} + className={cn( + "rounded-md px-3 py-1.5 text-sm transition-colors", + pathname.startsWith("/playground") + ? "text-foreground" + : "text-muted-foreground hover:text-foreground", + )} + > + Playground + setOpen(false)} /> diff --git a/components/app/playground/code-panel.tsx b/components/app/playground/code-panel.tsx new file mode 100644 index 0000000..592362d --- /dev/null +++ b/components/app/playground/code-panel.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { CopyButton } from "@/components/app/copy-button"; +import { cn } from "@/lib/utils"; + +export function CodePanel({ + code, + className, +}: { + code: string; + className?: string; +}) { + return ( +
+
+ +
+
+        {code}
+      
+
+ ); +} diff --git a/components/app/playground/controls.tsx b/components/app/playground/controls.tsx new file mode 100644 index 0000000..2a04116 --- /dev/null +++ b/components/app/playground/controls.tsx @@ -0,0 +1,97 @@ +"use client"; + +import type { ReactNode } from "react"; +import { RangeSlider } from "@/components/motion/range-slider"; +import type { ControlDef, ControlValue, Values } from "./core"; +import { CurveEditor } from "./curve-editor"; + +/** Decimal places implied by a step (0.05 -> 2, 1 -> 0). */ +function decimals(step: number) { + const s = String(step); + return s.includes(".") ? s.split(".")[1].length : 0; +} + +export function Controls({ + controls, + values, + onChange, +}: { + controls: ControlDef[]; + values: Values; + onChange: (key: string, value: ControlValue) => void; +}) { + return ( +
+ {controls.map((c) => { + if (c.kind === "slider") { + const v = typeof values[c.key] === "number" ? (values[c.key] as number) : c.min; + return ( + + ); + } + + if (c.kind === "select") { + const v = typeof values[c.key] === "string" ? (values[c.key] as string) : c.options[0]?.value; + return ( + + ); + } + + // curve + const v = Array.isArray(values[c.key]) ? (values[c.key] as number[]) : [0.16, 1, 0.3, 1]; + return ( +
+ + {c.label} + + [{v.map((n) => +n.toFixed(2)).join(", ")}] + + +
+ onChange(c.key, next)} /> +
+ {c.hint ? {c.hint} : null} +
+ ); + })} +
+ ); +} + +function Hint({ children }: { children: ReactNode }) { + return ( +

{children}

+ ); +} diff --git a/components/app/playground/core.ts b/components/app/playground/core.ts new file mode 100644 index 0000000..4c82bb3 --- /dev/null +++ b/components/app/playground/core.ts @@ -0,0 +1,74 @@ +import type { FC } from "react"; + +/** A single control's current value. */ +export type ControlValue = number | string | number[]; + +/** All control values for one playground type, keyed by control `key`. */ +export type Values = Record; + +/** + * A control rendered in the playground's left panel. Each type declares its own + * set; the shell renders them generically and never knows what they drive. + */ +export type ControlDef = + | { + kind: "slider"; + key: string; + label: string; + /** One-line plain-English definition shown under the control. */ + hint?: string; + min: number; + max: number; + step: number; + unit?: string; + } + | { + kind: "select"; + key: string; + label: string; + hint?: string; + options: { label: string; value: string }[]; + } + | { kind: "curve"; key: string; label: string; hint?: string }; + +export interface Preset { + name: string; + values: Values; +} + +/** + * A self-contained playground type (spring, tween, stagger...). Adding one is a + * single module plus a line in `items/index.ts` — the shell stays untouched. + */ +export interface PlaygroundItem { + slug: string; + label: string; + blurb: string; + /** Phase-2 types render in the sidebar as disabled "soon" rows. */ + comingSoon?: boolean; + controls: ControlDef[]; + defaults: Values; + presets: Preset[]; + /** The primitive subject that visualizes the current values. */ + Preview: FC<{ values: Values; replayKey: number }>; + /** Copy-paste `motion/react` snippet for the current values. */ + toCode: (values: Values) => string; +} + +/** Read a numeric control value with a fallback. */ +export function num(values: Values, key: string, fallback = 0): number { + const v = values[key]; + return typeof v === "number" ? v : fallback; +} + +/** Read a string control value with a fallback. */ +export function str(values: Values, key: string, fallback = ""): string { + const v = values[key]; + return typeof v === "string" ? v : fallback; +} + +/** Read a number[] control value (e.g. a cubic-bezier) with a fallback. */ +export function arr(values: Values, key: string, fallback: number[]): number[] { + const v = values[key]; + return Array.isArray(v) ? v : fallback; +} diff --git a/components/app/playground/curve-editor.tsx b/components/app/playground/curve-editor.tsx new file mode 100644 index 0000000..50524a1 --- /dev/null +++ b/components/app/playground/curve-editor.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useRef, useState } from "react"; + +const SIZE = 200; +const PAD = 34; // room for overshoot handles past the 0..1 core +const CORE = SIZE - PAD * 2; +// y domain allows overshoot beyond [0,1] (e.g. EASE_OUT peaks at y=1). +const Y_MIN = -0.4; +const Y_MAX = 1.4; + +const clamp = (n: number, lo: number, hi: number) => + Math.min(hi, Math.max(lo, n)); + +// domain (0..1 x, Y_MIN..Y_MAX y) -> svg pixels +const px = (x: number) => PAD + x * CORE; +const py = (y: number) => PAD + (Y_MAX - y) * (CORE / (Y_MAX - Y_MIN)); +// svg pixels -> domain +const toX = (p: number) => clamp((p - PAD) / CORE, 0, 1); +const toY = (p: number) => + clamp(Y_MAX - (p - PAD) / (CORE / (Y_MAX - Y_MIN)), Y_MIN, Y_MAX); + +export function CurveEditor({ + value, + onChange, +}: { + value: number[]; + onChange: (next: number[]) => void; +}) { + const ref = useRef(null); + const [drag, setDrag] = useState<0 | 1 | null>(null); + const [x1, y1, x2, y2] = value; + + const move = (e: React.PointerEvent, handle: 0 | 1) => { + const svg = ref.current; + if (!svg) return; + const rect = svg.getBoundingClientRect(); + const x = toX(((e.clientX - rect.left) / rect.width) * SIZE); + const y = toY(((e.clientY - rect.top) / rect.height) * SIZE); + onChange(handle === 0 ? [x, y, x2, y2] : [x1, y1, x, y]); + }; + + const start = px(0); + const startY = py(0); + const end = px(1); + const endY = py(1); + const c1x = px(x1); + const c1y = py(y1); + const c2x = px(x2); + const c2y = py(y2); + + return ( + drag !== null && move(e, drag)} + onPointerUp={() => setDrag(null)} + onPointerLeave={() => setDrag(null)} + aria-label="Easing curve editor" + > + {/* baseline 0 and 1 guides */} + + + + {/* handle leashes */} + + + + {/* the curve */} + + + {/* fixed endpoints */} + + + + {/* draggable control points */} + {([0, 1] as const).map((h) => ( + { + (e.target as SVGElement).setPointerCapture(e.pointerId); + setDrag(h); + }} + /> + ))} + + ); +} diff --git a/components/app/playground/items/index.ts b/components/app/playground/items/index.ts new file mode 100644 index 0000000..2202cef --- /dev/null +++ b/components/app/playground/items/index.ts @@ -0,0 +1,22 @@ +import type { PlaygroundItem } from "../core"; +import { springItem } from "./spring"; +import { staggerItem } from "./stagger"; +import { tweenItem } from "./tween"; + +/** + * Local playground catalog — NOT the shadcn registry (`lib/registry.ts`). These + * never ship as installable components. Phase-2 types appear as disabled + * "soon" rows until their module lands. + */ +export const PLAYGROUND_ITEMS: PlaygroundItem[] = [ + springItem, + tweenItem, + staggerItem, +]; + +export const PLAYGROUND_SOON: { slug: string; label: string }[] = [ + { slug: "keyframes", label: "Keyframes" }, + { slug: "gestures", label: "Gestures" }, + { slug: "layout", label: "Layout" }, + { slug: "scroll", label: "Scroll" }, +]; diff --git a/components/app/playground/items/spring.tsx b/components/app/playground/items/spring.tsx new file mode 100644 index 0000000..c0b8311 --- /dev/null +++ b/components/app/playground/items/spring.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useReducedMotion } from "motion/react"; +import { num, type PlaygroundItem, type Values } from "../core"; +import { TravelPreview } from "./travel-preview"; + +function SpringPreview({ + values, + replayKey, +}: { + values: Values; + replayKey: number; +}) { + const reduce = useReducedMotion(); + return ( + + ); +} + +export const springItem: PlaygroundItem = { + slug: "spring", + label: "Spring", + blurb: + "Physics-based motion. Stiffness pulls toward the target, damping resists, mass adds weight. Lower damping overshoots.", + controls: [ + { + kind: "slider", + key: "stiffness", + label: "Stiffness", + hint: "Pull toward the target. Higher = faster, snappier.", + min: 1, + max: 1000, + step: 1, + }, + { + kind: "slider", + key: "damping", + label: "Damping", + hint: "Resistance / friction. Lower = more bounce and overshoot.", + min: 1, + max: 100, + step: 1, + }, + { + kind: "slider", + key: "mass", + label: "Mass", + hint: "Weight of the object. Heavier = slower, more sluggish.", + min: 0.1, + max: 5, + step: 0.1, + }, + ], + defaults: { stiffness: 500, damping: 30, mass: 0.6 }, + presets: [ + { name: "Gentle", values: { stiffness: 120, damping: 14, mass: 1 } }, + { name: "Wobbly", values: { stiffness: 180, damping: 8, mass: 1 } }, + { name: "Stiff", values: { stiffness: 700, damping: 40, mass: 0.8 } }, + { name: "Slow", values: { stiffness: 80, damping: 20, mass: 1.5 } }, + ], + Preview: SpringPreview, + toCode: (v) => `import { motion } from "motion/react"; + +export function Demo() { + return ( + + ); +}`, +}; diff --git a/components/app/playground/items/stagger.tsx b/components/app/playground/items/stagger.tsx new file mode 100644 index 0000000..ab8cebc --- /dev/null +++ b/components/app/playground/items/stagger.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { motion, useReducedMotion } from "motion/react"; +import { num, type PlaygroundItem, str, type Values } from "../core"; + +function StaggerPreview({ + values, + replayKey, +}: { + values: Values; + replayKey: number; +}) { + const reduce = useReducedMotion(); + const count = Math.round(num(values, "count", 6)); + const stagger = num(values, "stagger", 0.06); + const delayChildren = num(values, "delayChildren", 0); + const direction = str(values, "direction", "forward") === "reverse" ? -1 : 1; + + return ( +
+ + {Array.from({ length: count }, (_, i) => ( + + ))} + +
+ ); +} + +export const staggerItem: PlaygroundItem = { + slug: "stagger", + label: "Stagger", + blurb: + "Children animate in sequence. Stagger sets the gap between each; delay holds the whole group before it starts.", + controls: [ + { + kind: "slider", + key: "count", + label: "Children", + hint: "How many items animate in.", + min: 3, + max: 12, + step: 1, + }, + { + kind: "slider", + key: "stagger", + label: "Stagger", + hint: "Gap between each child starting. Higher = more of a wave.", + min: 0, + max: 0.2, + step: 0.01, + unit: "s", + }, + { + kind: "slider", + key: "delayChildren", + label: "Delay", + hint: "Pause before the whole group begins.", + min: 0, + max: 0.6, + step: 0.05, + unit: "s", + }, + { + kind: "select", + key: "direction", + label: "Direction", + hint: "Order children fire in.", + options: [ + { label: "Forward", value: "forward" }, + { label: "Reverse", value: "reverse" }, + ], + }, + ], + defaults: { count: 6, stagger: 0.06, delayChildren: 0, direction: "forward" }, + presets: [ + { name: "Tight", values: { stagger: 0.03, delayChildren: 0 } }, + { name: "Relaxed", values: { stagger: 0.08, delayChildren: 0.1 } }, + { name: "Dramatic", values: { stagger: 0.14, delayChildren: 0.2 } }, + ], + Preview: StaggerPreview, + toCode: (v) => { + const dir = str(v, "direction", "forward") === "reverse" ? -1 : 1; + return `import { motion } from "motion/react"; + +const container = { + show: { + transition: { + staggerChildren: ${num(v, "stagger", 0.06)}, + delayChildren: ${num(v, "delayChildren", 0)}, + staggerDirection: ${dir}, + }, + }, +}; + +const child = { + hidden: { opacity: 0, scale: 0.4, y: 12 }, + show: { opacity: 1, scale: 1, y: 0 }, +}; + +export function Demo() { + return ( + + {items.map((item) => ( + + ))} + + ); +}`; + }, +}; diff --git a/components/app/playground/items/travel-preview.tsx b/components/app/playground/items/travel-preview.tsx new file mode 100644 index 0000000..0e4320e --- /dev/null +++ b/components/app/playground/items/travel-preview.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { motion, type Transition } from "motion/react"; + +const TRAVEL = 168; + +/** + * Shared subject for time/physics types: a box travels a fixed distance from a + * dashed "start" ghost to a "target" guide. Springs visibly overshoot the line + * before settling, so a first-timer can see what the numbers do. + */ +export function TravelPreview({ + transition, + replayKey, +}: { + transition: Transition; + replayKey: number; +}) { + return ( +
+
+ {/* start ghost */} +
+ + start + + + {/* target guide */} +
+ + target + + + {/* moving box */} + +
+
+ ); +} diff --git a/components/app/playground/items/tween.tsx b/components/app/playground/items/tween.tsx new file mode 100644 index 0000000..9d92690 --- /dev/null +++ b/components/app/playground/items/tween.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useReducedMotion } from "motion/react"; +import { arr, num, type PlaygroundItem, type Values } from "../core"; +import { TravelPreview } from "./travel-preview"; + +const FALLBACK = [0, 0, 0.58, 1]; + +function TweenPreview({ + values, + replayKey, +}: { + values: Values; + replayKey: number; +}) { + const reduce = useReducedMotion(); + return ( + + ); +} + +const fmt = (e: number[]) => `[${e.map((n) => +n.toFixed(3)).join(", ")}]`; + +export const tweenItem: PlaygroundItem = { + slug: "tween", + label: "Tween / Ease", + blurb: + "Time-based motion. Duration sets how long; the cubic-bezier curve sets the pacing. Drag the handles to shape it.", + controls: [ + { + kind: "slider", + key: "duration", + label: "Duration", + hint: "How long the animation takes, start to finish.", + min: 0.1, + max: 3, + step: 0.05, + unit: "s", + }, + { + kind: "curve", + key: "curve", + label: "Easing curve", + hint: "Pacing over time. Drag the handles — steep = fast, flat = slow.", + }, + ], + defaults: { duration: 0.6, curve: [0, 0, 0.58, 1] }, + presets: [ + { name: "linear", values: { curve: [0, 0, 1, 1] } }, + { name: "easeIn", values: { curve: [0.42, 0, 1, 1] } }, + { name: "easeOut", values: { curve: [0, 0, 0.58, 1] } }, + { name: "easeInOut", values: { curve: [0.42, 0, 0.58, 1] } }, + ], + Preview: TweenPreview, + toCode: (v) => `import { motion } from "motion/react"; + +export function Demo() { + return ( + + ); +}`, +}; diff --git a/components/app/playground/playground.tsx b/components/app/playground/playground.tsx new file mode 100644 index 0000000..657660a --- /dev/null +++ b/components/app/playground/playground.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { RotateCw } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Button } from "@/components/motion/button"; +import { cn } from "@/lib/utils"; +import { CodePanel } from "./code-panel"; +import { Controls } from "./controls"; +import type { ControlValue, Preset, Values } from "./core"; +import { PLAYGROUND_ITEMS, PLAYGROUND_SOON } from "./items"; + +/** Quick-start preset chips. Plain starting points to play from — the type's + * own defaults load first. */ +function PresetSection({ + presets, + onApply, +}: { + presets: Preset[]; + onApply: (values: Values) => void; +}) { + if (presets.length === 0) return null; + return ( +
+ + Try + +
+ {presets.map((p) => ( + + ))} +
+
+ ); +} + +export function Playground() { + const [activeSlug, setActiveSlug] = useState(PLAYGROUND_ITEMS[0].slug); + const [replayKey, setReplayKey] = useState(0); + // per-type values so switching types preserves each one's tweaks + const [valuesByType, setValuesByType] = useState>(() => + Object.fromEntries(PLAYGROUND_ITEMS.map((it) => [it.slug, { ...it.defaults }])), + ); + + const active = useMemo( + () => PLAYGROUND_ITEMS.find((it) => it.slug === activeSlug) ?? PLAYGROUND_ITEMS[0], + [activeSlug], + ); + const values = valuesByType[active.slug]; + + const replay = () => setReplayKey((k) => k + 1); + + const select = (slug: string) => { + setActiveSlug(slug); + replay(); + }; + + const setValue = (key: string, value: ControlValue) => { + // strip float artifacts from step snapping (0.30000000000000004 -> 0.3) + const clean = + typeof value === "number" ? Math.round(value * 1e6) / 1e6 : value; + setValuesByType((prev) => ({ + ...prev, + [active.slug]: { ...prev[active.slug], [key]: clean }, + })); + replay(); + }; + + const applyPreset = (presetValues: Values) => { + setValuesByType((prev) => ({ + ...prev, + [active.slug]: { ...prev[active.slug], ...presetValues }, + })); + replay(); + }; + + const Preview = active.Preview; + + return ( +
+
+

+ Playground +

+

+ Tweak motion properties, watch them play, copy the code. Built on the + same tokens the components use. +

+
+ + {/* mobile type switcher */} +
+ +
+ +
+ {/* sidebar */} + + + {/* main */} +
+ {/* vocab: define the type for first-timers */} +
+

{active.label}

+

+ {active.blurb} +

+
+ +
+ {/* left column: preview, then code stacked at the same width */} +
+
+
+ Preview + +
+
+ +
+
+ + +
+ + {/* right column: controls, full height */} +
+ +
+ +
+
+
+
+
+
+ ); +} diff --git a/components/app/site-header.tsx b/components/app/site-header.tsx index dada137..7d5104c 100644 --- a/components/app/site-header.tsx +++ b/components/app/site-header.tsx @@ -1,18 +1,17 @@ "use client"; -import Link from "next/link"; -import Image from "next/image"; +import { Star, SwatchBook } from "lucide-react"; import { useMotionValueEvent, useScroll } from "motion/react"; -import { Star } from "lucide-react"; -import { useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; import { usePathname } from "next/navigation"; -import { SwatchBook } from "lucide-react"; +import { useState } from "react"; import { GithubIcon } from "@/components/app/icons"; import { MobileNav } from "@/components/app/mobile-nav"; +import { usePreferences } from "@/components/app/preferences-provider"; import { PressLink } from "@/components/app/press-link"; import { SiteSearch } from "@/components/app/site-search"; import { Tooltip } from "@/components/motion/tooltip"; -import { usePreferences } from "@/components/app/preferences-provider"; import { cn } from "@/lib/utils"; function formatStarCount(count: number) { @@ -37,6 +36,7 @@ export function SiteHeader({ (pathname.startsWith("/components") && !pathname.startsWith("/components/blocks")); const isBlocks = pathname.startsWith("/components/blocks"); + const isPlayground = pathname.startsWith("/playground"); const formattedStarCount = typeof githubStarCount === "number" ? formatStarCount(githubStarCount) @@ -95,6 +95,17 @@ export function SiteHeader({ > Blocks + + Playground +
From 4cbaa5c92ad1781f170c2cb01c2ed94e1b0800f2 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Sat, 27 Jun 2026 08:27:39 +0530 Subject: [PATCH 02/10] feat: clearer playground previews and highlighted code Strobe trail and easing meaning box make tween curves legible, shiki syntax highlighting on the live code panel. --- components/app/playground/code-panel.tsx | 57 +++++++++++++++++-- components/app/playground/controls.tsx | 6 +- .../app/playground/items/travel-preview.tsx | 34 ++++++++++- components/app/playground/items/tween.tsx | 52 ++++++++++++++++- components/app/playground/playground.tsx | 31 +++++++--- 5 files changed, 165 insertions(+), 15 deletions(-) diff --git a/components/app/playground/code-panel.tsx b/components/app/playground/code-panel.tsx index 592362d..fa72e49 100644 --- a/components/app/playground/code-panel.tsx +++ b/components/app/playground/code-panel.tsx @@ -1,8 +1,22 @@ "use client"; +import { useEffect, useState } from "react"; +import { getSingletonHighlighter, type Highlighter } from "shiki"; import { CopyButton } from "@/components/app/copy-button"; import { cn } from "@/lib/utils"; +// One shared highlighter for the page, created lazily on first render. +let hlPromise: Promise | null = null; +function highlighter() { + if (!hlPromise) { + hlPromise = getSingletonHighlighter({ + themes: ["github-light", "github-dark"], + langs: ["tsx"], + }); + } + return hlPromise; +} + export function CodePanel({ code, className, @@ -10,19 +24,54 @@ export function CodePanel({ code: string; className?: string; }) { + const [html, setHtml] = useState(null); + + useEffect(() => { + let cancelled = false; + highlighter().then((hl) => { + if (cancelled) return; + setHtml( + hl.codeToHtml(code, { + lang: "tsx", + themes: { light: "github-light", dark: "github-dark" }, + defaultColor: false, + }), + ); + }); + return () => { + cancelled = true; + }; + }, [code]); + return (
-
-        {code}
-      
+ + {html ? ( +
+ ) : ( + // pre-highlight fallback keeps layout stable on first paint +
+          {code}
+        
+ )}
); } diff --git a/components/app/playground/controls.tsx b/components/app/playground/controls.tsx index 2a04116..7d51ca3 100644 --- a/components/app/playground/controls.tsx +++ b/components/app/playground/controls.tsx @@ -26,7 +26,9 @@ export function Controls({ if (c.kind === "slider") { const v = typeof values[c.key] === "number" ? (values[c.key] as number) : c.min; return ( -