About
A rotating torus, cube, octahedron or your own logo, drawn as ASCII in the donut.c tradition but ray marched per cell. Drag to spin it, flick to throw it, or use the arrow keys.
Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds ASCII 3D to your project and fits it to your theme.
Code
How ASCII 3D is used: the demo's code. The component's own source comes with a free account.
Usage.tsx
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Toggle } from "@base-ui/react/toggle"
import { ToggleGroup } from "@base-ui/react/toggle-group"
import { Ascii3D, Ascii3DPath, Ascii3DShape } from "@/components/ui/ascii-3d"
const FIT_CSS = "html,body{overflow:hidden}[data-fit-small] [data-fit-hide]{display:none!important}"
/**
* 21st shows a demo in a small frame: about 674 by 421 on a component page, 358 by 224 on a
* phone and 245 by 139 on a profile card. A demo taller than its frame gets scrollbars, which
* render white on dark pages. FitFrame fills the frame exactly instead.
*
* - "scale": the content shrinks until it fits, centered. For visuals such as orbs.
* - "crop": the content stays at full size from the top and fades out at the bottom. For text
* that streams in, where a changing scale would make the page breathe.
*
* Anything marked `data-fit-hide` (state buttons, the chatcn.dev line) drops out in frames
* shorter than `hideBelow`, so a profile card shows the visual alone.
*/
function FitFrame({
children,
mode = "scale",
hideBelow = 360,
}: {
children: React.ReactNode
mode?: "scale" | "crop"
hideBelow?: number
}) {
const content = React.useRef<HTMLDivElement>(null)
const [cropped, setCropped] = React.useState(false)
React.useLayoutEffect(() => {
const el = content.current
if (!el) return
const measure = () => {
const vw = window.innerWidth
const vh = window.innerHeight
el.toggleAttribute("data-fit-small", vh < hideBelow)
// Layout sizes ignore the transform, so measuring never feeds back.
const w = Math.max(el.offsetWidth, el.scrollWidth)
const h = Math.max(el.offsetHeight, el.scrollHeight)
const scale = mode === "scale" ? Math.min(1, vw / w, vh / h) : 1
el.style.transform = scale < 1 ? `scale(${scale})` : ""
setCropped(mode === "crop" && h > vh + 1)
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(el)
window.addEventListener("resize", measure)
return () => {
observer.disconnect()
window.removeEventListener("resize", measure)
}
}, [mode, hideBelow])
const fade = "linear-gradient(to bottom, #000 calc(100% - 40px), transparent)"
return (
<div
className="flex w-full justify-center overflow-hidden"
style={{
height: "100dvh",
alignItems: mode === "crop" ? "flex-start" : "center",
maskImage: cropped ? fade : undefined,
WebkitMaskImage: cropped ? fade : undefined,
}}
>
<style>{FIT_CSS}</style>
<div
ref={content}
className="w-full shrink-0"
style={{ transformOrigin: mode === "crop" ? "top center" : "center" }}
>
{children}
</div>
</div>
)
}
const SHAPES = [
{ value: "torus", label: "Torus" },
{ value: "cube", label: "Cube" },
{ value: "octahedron", label: "Octahedron" },
{ value: "logo", label: "Logo" },
] as const
/** The chatcn mark, a "c" and the block caret, on its 32 unit grid. */
const CHATCN_MARK: Ascii3DPath = {
viewBox: [0, 0, 32, 32],
path: "M9.45 23.67L9.45 23.67Q7.20 23.67 5.52 22.71Q3.83 21.75 2.92 20.04Q2.00 18.33 2.00 16L2.00 16Q2.00 13.70 2.92 11.97Q3.83 10.25 5.52 9.29Q7.20 8.33 9.45 8.33L9.45 8.33Q11.40 8.33 12.94 9.01Q14.49 9.70 15.45 10.99Q16.41 12.27 16.63 14.08L16.63 14.08L12.38 14.27Q12.19 12.90 11.41 12.21Q10.63 11.51 9.45 11.51L9.45 11.51Q7.92 11.51 7.07 12.68Q6.22 13.86 6.22 16L6.22 16Q6.22 18.14 7.07 19.33Q7.92 20.52 9.45 20.52L9.45 20.52Q10.63 20.52 11.42 19.79Q12.22 19.07 12.38 17.59L12.38 17.59L16.63 17.75Q16.44 19.56 15.49 20.89Q14.55 22.22 13.00 22.95Q11.45 23.67 9.45 23.67ZM19.75 8.66h9.06a1.2 1.2 0 0 1 1.2 1.2v12.29a1.2 1.2 0 0 1 -1.2 1.2h-9.06a1.2 1.2 0 0 1 -1.2 -1.2v-12.29a1.2 1.2 0 0 1 1.2 -1.2Z",
}
/** A labeled segmented control on Base UI ToggleGroup. One value is always selected. */
function Segmented<T extends string>({
label,
value,
onValueChange,
options,
className,
}: {
label: string
value: T
onValueChange: (value: T) => void
options: readonly { value: T; label: string }[]
className?: string
}) {
return (
<div className={cn("flex min-w-0 flex-col gap-1.5", className)}>
<span className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-subtle) font-medium">{label}</span>
<ToggleGroup
value={[value]}
onValueChange={(next) => {
const picked = next[0] as T | undefined
if (picked) onValueChange(picked)
}}
aria-label={label}
className="bg-(color:--chatcn-fill) rounded-(--chatcn-radius-md) flex h-8 w-fit max-w-full items-center gap-0.5 overflow-x-auto p-0.5 pointer-coarse:h-11"
>
{options.map((option) => (
<Toggle
key={option.value}
value={option.value}
className={cn(
"chatcn-focus rounded-(--chatcn-radius-sm) text-[0.8125rem]/[1.25rem] inline-flex h-full shrink-0 items-center px-2.5 font-medium whitespace-nowrap",
"text-(color:--chatcn-fg-subtle) ease-(--chatcn-ease-standard) transition-[color,background-color,box-shadow] duration-150",
"hover:text-(color:--chatcn-fg) data-[pressed]:bg-(color:--chatcn-card) data-[pressed]:text-(color:--chatcn-fg) data-[pressed]:shadow-(--chatcn-shadow-xs)",
)}
>
{option.label}
</Toggle>
))}
</ToggleGroup>
</div>
)
}
/** A torus in the donut.c tradition, ray marched per cell. Drag it, flick it, or use the arrow keys. */
function Ascii3DDemo() {
const [shape, setShape] = React.useState<(typeof SHAPES)[number]["value"]>("torus")
const value: Ascii3DShape = shape === "logo" ? CHATCN_MARK : shape
return (
<div className="flex w-full max-w-md flex-col items-center gap-5">
<Ascii3D
shape={value}
columns={shape === "logo" ? 80 : 60}
className="text-(color:--chatcn-fg) w-full"
/>
<Segmented label="Shape" value={shape} onValueChange={setShape} options={SHAPES} />
</div>
)
}
/** The line under each 21st demo that points back to the kit. */
function MoreOn({ slug, children }: { slug: string; children: React.ReactNode }) {
return (
<p data-fit-hide className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-faint) text-center text-balance">
{children}{" "}
<a
href={`https://chatcn.dev/docs/components/${slug}`}
target="_blank"
rel="noopener"
className="text-(color:--chatcn-fg-subtle) hover:text-(color:--chatcn-fg) underline underline-offset-2"
>
chatcn.dev
</a>
</p>
)
}
export default function Ascii3DPreview() {
return (
<FitFrame>
<div className="flex w-full flex-col items-center justify-center gap-10 p-6">
<Ascii3DDemo />
<MoreOn slug="ascii-3d">
Drag it, flick it, or use the arrow keys. More ASCII effects on
</MoreOn>
</div>
</FitFrame>
)
}







