Skip to content
Open
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Before building a new component, check this list. If it exists, import it. If it
| `marquee` | `components/motion/marquee.tsx` | Infinite horizontal or vertical scroll, pause-on-hover |
| `tabs` | `components/motion/tabs.tsx` | Pill, segment or underline tabs with spring layoutId indicator |
| `switch` | `components/motion/switch.tsx` | Toggle with spring-driven thumb and press feedback |
| `select` | `components/motion/select.tsx` | Composable select primitives (`Select`, `SelectTrigger`, `SelectValue`, `SelectContent`, `SelectItem`); panel bouncily unfolds out of the trigger and separates, with staggered items |
| `range-slider` | `components/motion/range-slider.tsx` | Range slider (`RangeSlider`) with tick dots and a bouncy vertical-bar thumb that glides between snapped steps; drag + keyboard, controlled/uncontrolled |
| `bottom-sheet` | `components/motion/bottom-sheet.tsx` | Draggable bottom sheet with snap points, inertia and glass surface |
| `shared-layout-bg` | `components/motion/shared-layout-bg.tsx` | Pill that glides between hovered items via shared layout |
Expand Down
12 changes: 12 additions & 0 deletions app/playground/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <Playground />;
}
16 changes: 14 additions & 2 deletions components/app/mobile-nav.tsx
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -64,6 +64,18 @@ export function MobileNav() {
>
Blocks
</Link>
<Link
href="/playground"
onClick={() => 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
</Link>
</nav>
<SidebarNav onNavigate={() => setOpen(false)} />
</div>
Expand Down
77 changes: 77 additions & 0 deletions components/app/playground/code-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"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<Highlighter> | null = null;
function highlighter() {
if (!hlPromise) {
hlPromise = getSingletonHighlighter({
themes: ["github-light", "github-dark"],
langs: ["tsx"],
});
}
return hlPromise;
}

export function CodePanel({
code,
className,
}: {
code: string;
className?: string;
}) {
const [html, setHtml] = useState<string | null>(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 (
<div
className={cn(
"group relative overflow-hidden rounded-xl border border-border bg-card font-mono text-[13px]",
className,
)}
>
<div className="absolute right-3 top-3 z-10">
<CopyButton text={code} />
</div>

{html ? (
<div
className={cn(
"py-4 text-[13px] leading-relaxed",
"[&_pre]:!bg-transparent [&_pre]:overflow-x-auto [&_pre]:!p-0",
"[&_code]:font-mono [&_code]:text-[13px]",
"[&_.shiki]:bg-transparent",
"[&_.line]:px-5",
)}
// shiki output; input is our own generated source, not user content
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
// pre-highlight fallback keeps layout stable on first paint
<pre className="overflow-x-auto px-5 py-4 leading-relaxed text-foreground">
<code>{code}</code>
</pre>
)}
</div>
);
}
197 changes: 197 additions & 0 deletions components/app/playground/controls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
"use client";

import { Plus, X } from "lucide-react";
import type { ReactNode } from "react";
import { RangeSlider } from "@/components/motion/range-slider";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/motion/select";
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 (
<div className="flex flex-col gap-5">
{controls.map((c) => {
if (c.kind === "slider") {
const v = typeof values[c.key] === "number" ? (values[c.key] as number) : c.min;
return (
// not a <label>: RangeSlider is a custom control (carries its own
// aria-label), so a wrapping label would have no associated input.
<div key={c.key} className="block">
<span className="flex items-baseline justify-between text-sm">
<span className="font-medium text-foreground">{c.label}</span>
<span className="font-mono text-xs text-muted-foreground tabular-nums">
{+v.toFixed(decimals(c.step))}
{c.unit ?? ""}
</span>
</span>
<RangeSlider
className="mt-2"
aria-label={c.label}
min={c.min}
max={c.max}
step={c.step}
value={v}
onValueChange={(next) => onChange(c.key, next)}
/>
{c.hint ? <Hint>{c.hint}</Hint> : null}
</div>
);
}

if (c.kind === "select") {
const v = typeof values[c.key] === "string" ? (values[c.key] as string) : c.options[0]?.value;
return (
<div key={c.key} className="block">
<span className="text-sm font-medium text-foreground">{c.label}</span>
<div className="mt-2">
<Select value={v} onValueChange={(next) => onChange(c.key, next)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{c.options.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{c.hint ? <Hint>{c.hint}</Hint> : null}
</div>
);
}

if (c.kind === "numberlist") {
const list = Array.isArray(values[c.key])
? (values[c.key] as number[])
: [];
const minItems = c.minItems ?? 2;
const maxItems = c.maxItems ?? 8;
const b = c.bounds?.(values) ?? { min: 0, max: 100, step: 1 };
const dec = decimals(b.step);
const setAt = (i: number, val: number) =>
onChange(
c.key,
list.map((n, idx) => (idx === i ? val : n)),
);
const removeAt = (i: number) =>
onChange(
c.key,
list.filter((_, idx) => idx !== i),
);
const add = () =>
onChange(c.key, [...list, list[list.length - 1] ?? b.min]);

return (
<div key={c.key} className="block">
<span className="text-sm font-medium text-foreground">
{c.label}
</span>
<div className="mt-3 flex flex-col gap-4">
{list.map((n, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: positional checkpoint slots
<div key={i}>
<span className="flex items-baseline justify-between text-xs">
<span className="font-medium text-muted-foreground">
Checkpoint {i + 1}
{i === 0
? " (start)"
: i === list.length - 1
? " (end)"
: ""}
</span>
<span className="flex items-center gap-1.5">
<span className="font-mono text-muted-foreground tabular-nums">
{+n.toFixed(dec)}
</span>
{list.length > minItems ? (
<button
type="button"
onClick={() => removeAt(i)}
aria-label={`Remove checkpoint ${i + 1}`}
className="flex h-4 w-4 items-center justify-center rounded-full border border-border text-muted-foreground transition-colors hover:text-foreground"
>
<X className="h-2.5 w-2.5" />
</button>
) : null}
</span>
</span>
<RangeSlider
className="mt-1.5"
aria-label={`Checkpoint ${i + 1}`}
min={b.min}
max={b.max}
step={b.step}
value={n}
onValueChange={(val) => setAt(i, val)}
/>
{c.describe ? (
<p className="mt-1 text-xs text-muted-foreground">
{c.describe(n, values)}
</p>
) : null}
</div>
))}
{list.length < maxItems ? (
<button
type="button"
onClick={add}
className="inline-flex items-center gap-1.5 self-start rounded-full border border-dashed border-border px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<Plus className="h-3.5 w-3.5" />
Add checkpoint
</button>
) : null}
</div>
{c.hint ? <Hint>{c.hint}</Hint> : null}
</div>
);
}

// curve
const v = Array.isArray(values[c.key]) ? (values[c.key] as number[]) : [0.16, 1, 0.3, 1];
return (
<div key={c.key}>
<span className="flex items-baseline justify-between text-sm">
<span className="font-medium text-foreground">{c.label}</span>
<span className="font-mono text-xs text-muted-foreground">
[{v.map((n) => +n.toFixed(2)).join(", ")}]
</span>
</span>
<div className="mt-2 max-w-[240px]">
<CurveEditor value={v} onChange={(next) => onChange(c.key, next)} />
</div>
{c.hint ? <Hint>{c.hint}</Hint> : null}
</div>
);
})}
</div>
);
}

function Hint({ children }: { children: ReactNode }) {
return (
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">{children}</p>
);
}
Loading
Loading