diff --git a/docs/go/carousel.go b/docs/go/carousel.go
new file mode 100644
index 00000000..c3b5382e
--- /dev/null
+++ b/docs/go/carousel.go
@@ -0,0 +1,87 @@
+package dangdocs
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/vito/booklit"
+ "github.com/vito/dang/v2/pkg/dang"
+)
+
+// DangCarousel renders a feature carousel: a strip of \dang-feature slides,
+// each a titled, distinctive-feature showcase. docs/js/carousel.js
+// progressively enhances it into a tabbed, arrow-navigated reel; without
+// JavaScript every slide renders stacked and fully readable.
+//
+// \dang-carousel{
+// \dang-feature{Prototype objects}{{{
+// ...code...
+// }}}
+// }{
+// \dang-feature{Copy-on-write}{{{
+// ...code...
+// }}}
+// }
+func (p Plugin) DangCarousel(slides ...booklit.Content) booklit.Content {
+ return booklit.Styled{
+ Style: "carousel",
+ Content: booklit.Sequence(slides),
+ Block: true,
+ }
+}
+
+// DangFeature renders one carousel slide: a feature title above a Dang snippet
+// rendered exactly as a \dang-literate block — same template, styling, baked
+// output, and client-side editor (see literate.go / docs/js/playground.js), so
+// the carousel reuses the site's one code-snippet mechanism rather than
+// inventing another.
+//
+// The one difference from a page literate block is isolation: each slide
+// evaluates in its own fresh standard-library session (not the page-shared
+// chain), since slides are independent showcases that each (re)declare their
+// own types. The slide wrapper carries data-dang-literate-chain so playground.js
+// replays it as a standalone chain client-side too. As with any literate block
+// the snippet is evaluated at build time and a parse/type/eval failure fails
+// the docs build, so the examples can't rot.
+//
+// \dang-feature{Prototype objects}{{{
+// type Greeter { name: String! greet: String! { `hi ${name}` } }
+// Greeter("world").greet
+// }}}
+func (p Plugin) DangFeature(title, code booklit.Content) (booklit.Content, error) {
+ source := strings.TrimRight(code.String(), "\n")
+
+ // A fresh session per slide keeps slides independent — one slide's
+ // declarations must not leak into the next.
+ typeScope, valueScope := dang.BuildScopesFromImports("", nil)
+ sess := &literateSession{typeScope: typeScope, valueScope: valueScope}
+
+ stdout, value, err := literateEval(source, sess)
+ if err != nil {
+ return nil, fmt.Errorf(`\dang-feature %q in %s: %w`, title.String(), p.section.FilePath(), err)
+ }
+
+ // Build the same Styled "dang-literate" content a \dang-literate block
+ // produces (literate.go's literateBlock), so it renders through
+ // dang-literate.tmpl identically.
+ partials := booklit.Partials{}
+ if stdout != "" {
+ partials["Stdout"] = booklit.String(stdout)
+ }
+ if value != "" {
+ partials["Value"] = highlightResult(value)
+ }
+ literate := booklit.Styled{
+ Style: "dang-literate",
+ Content: p.highlightDang(source),
+ Partials: partials,
+ Block: true,
+ }
+
+ return booklit.Styled{
+ Style: "carousel-slide",
+ Content: literate,
+ Partials: booklit.Partials{"Title": title},
+ Block: true,
+ }, nil
+}
diff --git a/docs/html/carousel-slide.tmpl b/docs/html/carousel-slide.tmpl
new file mode 100644
index 00000000..56541434
--- /dev/null
+++ b/docs/html/carousel-slide.tmpl
@@ -0,0 +1,4 @@
+
+
{{.Partial "Title" | render}}
+{{.Content | render}}
+
diff --git a/docs/html/carousel.tmpl b/docs/html/carousel.tmpl
new file mode 100644
index 00000000..c3b7afd6
--- /dev/null
+++ b/docs/html/carousel.tmpl
@@ -0,0 +1 @@
+{{.Content | render}}
diff --git a/docs/html/page.tmpl b/docs/html/page.tmpl
index be43abf6..b28f21e0 100644
--- a/docs/html/page.tmpl
+++ b/docs/html/page.tmpl
@@ -268,6 +268,59 @@ pre code{display:block}
.dang-literate:focus-within .dang-literate-controls{opacity:1}
@media (hover:none){.dang-literate-controls{opacity:1}}
+/* ── feature carousel (carousel.js) ──────────────────────────────────── */
+/* A showcase reel of distinctive features; each \dang-feature slide is a
+ titled, build-evaluated snippet. carousel.js adds .is-enhanced, which is
+ what collapses the stacked slides into a tabbed, arrow-navigated panel —
+ without JS every slide just renders in sequence, fully readable. */
+.dang-carousel{
+ border:1px solid var(--code-border);border-radius:8px;margin:1.5rem 0;
+ background:var(--bg2);overflow:hidden;
+}
+/* Tab strip of feature names, built by carousel.js — the menu of "what's
+ different", and the primary jump-to-slide control. Scrolls sideways rather
+ than wrapping on a narrow viewport so it never towers over the code. */
+.dang-carousel-tabs{
+ display:flex;gap:.25rem;padding:.5rem .5rem 0;overflow-x:auto;
+ border-bottom:1px solid var(--code-border);background:var(--bg2);
+}
+.dang-carousel-tab{
+ font-family:'Inter',system-ui,sans-serif;font-size:.76rem;font-weight:500;cursor:pointer;
+ border:1px solid transparent;border-bottom:none;background:transparent;color:var(--fg2);
+ padding:.35rem .65rem;border-radius:6px 6px 0 0;line-height:1.3;white-space:nowrap;
+ transition:color .12s,background .12s;
+}
+.dang-carousel-tab:hover{color:var(--fg);background:var(--bg3)}
+.dang-carousel-tab.is-active{color:var(--accent2);background:var(--code-bg);border-color:var(--code-border)}
+/* Enhanced: only the active slide is shown. */
+.dang-carousel.is-enhanced .dang-carousel-slide{display:none}
+.dang-carousel.is-enhanced .dang-carousel-slide.is-active{display:block}
+/* Unenhanced fallback: stack the slides with a divider between them. */
+.dang-carousel-slide + .dang-carousel-slide{border-top:1px solid var(--code-border)}
+.dang-carousel-title{
+ font-size:.82rem;font-weight:600;color:var(--fg);
+ padding:.6rem .9rem .1rem;
+}
+/* Each slide embeds a real \dang-literate block; strip its standalone framing
+ so it sits flush inside the carousel rather than as a bordered card. */
+.dang-carousel-slide .dang-literate{margin:0;border:none;border-radius:0;background:var(--code-bg)}
+/* Prev / counter / next, built by carousel.js. */
+.dang-carousel-foot{
+ display:flex;align-items:center;justify-content:flex-end;gap:.5rem;
+ padding:.4rem .6rem;background:var(--bg2);border-top:1px solid var(--code-border);
+}
+.dang-carousel-arrow{
+ font-family:'Inter',system-ui,sans-serif;font-size:.9rem;cursor:pointer;
+ border:1px solid var(--code-border);background:var(--bg3);color:var(--fg);
+ width:1.7rem;height:1.7rem;border-radius:5px;line-height:1;
+ display:flex;align-items:center;justify-content:center;transition:border-color .12s,color .12s;
+}
+.dang-carousel-arrow:hover{border-color:var(--fg2);color:var(--accent2)}
+.dang-carousel-count{
+ font-size:.74rem;color:var(--fg2);font-family:'JetBrains Mono',monospace;
+ min-width:3rem;text-align:center;
+}
+
table{width:100%;border-collapse:collapse;font-size:.85rem;margin:.75rem 0}
th{text-align:left;padding:.5rem .6rem;color:var(--fg2);font-weight:500;border-bottom:1px solid var(--code-border)}
td{padding:.4rem .6rem;border-bottom:1px solid var(--td-border)}
@@ -366,5 +419,6 @@ footer{margin-top:4rem;padding-top:1.5rem;border-top:1px solid var(--code-border
+