diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml
index 1862859..cb8d43c 100644
--- a/.github/workflows/deploy-website.yml
+++ b/.github/workflows/deploy-website.yml
@@ -68,18 +68,22 @@ jobs:
set -e
ls -la
test -f index.html || { echo "ERROR: index.html not found"; exit 1; }
+ test -f docs/index.html || { echo "ERROR: docs page not found"; exit 1; }
test -f main.client.dart.js || { echo "ERROR: client bundle not found"; exit 1; }
echo "Build output OK"
# The project Pages site lives at the /dnd_kit/ subpath. jaspr build has no
# --base-href flag and Document emits , so rewrite it here
- # (asset URLs in index.html are relative and resolve against this base).
+ # on every generated page (relative asset and cross-page URLs resolve
+ # against this base).
- name: Set base href for the project subpath
working-directory: website/build/jaspr
run: |
set -e
- sed -i 's|||' index.html
+ find . -name '*.html' -exec \
+ sed -i 's|||' {} +
grep -q '' index.html
+ grep -q '' docs/index.html
touch .nojekyll
- name: Upload Pages artifact
diff --git a/README.md b/README.md
index 3af179d..d8e2b4e 100644
--- a/README.md
+++ b/README.md
@@ -78,8 +78,11 @@ workspace unification through `US-061`, and the Jaspr sortable preset through
axis-aware shared-core implementation slice, and the Flutter execution-layer
adoption through `US-065`, plus the Jaspr execution-layer adoption through
`US-066`, plus the Jaspr example feature gallery through `US-067`, plus the
-Jaspr drag overlay controller rebind fix through `US-068`, and the shared
-experimental multi-container parity slice through `US-076`.
+Jaspr drag overlay controller rebind fix through `US-068`, the shared
+experimental multi-container parity slice through `US-076`, the
+production-ready multi-container graduation and website showcase through
+`US-078`, and the coordinated family stable `0.4.0` publication (`dnd_kit`,
+`dnd_kit_flutter`, `dnd_kit_jaspr`) through `US-079`.
The living source of truth is split from historical [SPEC.md](SPEC.md) input
material into product docs, story packets, validation expectations, and decision
diff --git a/docs/product/release-roadmap.md b/docs/product/release-roadmap.md
index 98f5b2a..1159f81 100644
--- a/docs/product/release-roadmap.md
+++ b/docs/product/release-roadmap.md
@@ -317,6 +317,24 @@ publication after the multi-container graduation work has soaked:
Phase README:
`docs/stories/phase-31-coordinated-family-stable-0-4-0-release/README.md`.
+## Phase 32 - Website Docs Page And Post-0.4.0 Doc Alignment
+
+Close the docs/adoption gap left after the stable `0.4.0` publication:
+
+- `US-080`: give the website a real Getting Started docs page on its own `/docs`
+ route (replacing the in-page `#docs` placeholder) and align the root README
+ status plus the Jaspr/Flutter package READMEs with the shipped stable `0.4.0`
+ family;
+- `US-081`: expand `/docs` into a multi-page docs section (shared sidebar shell,
+ right-rail TOC, prev/next pager, Flutter|Jaspr code tabs) covering the nine
+ core pages;
+- `US-082`: complete the coverage with four more Concepts pages (collision,
+ sensors, modifiers, auto-scroll), an API Reference page, and a collapsible
+ mobile docs menu.
+
+Phase README:
+`docs/stories/phase-32-website-docs-page-and-doc-alignment/README.md`.
+
## Current State
The repository has implemented work through `US-079`. The Flutter adapter, the
@@ -360,6 +378,16 @@ packet for that line: the prepared `0.4.0-dev.1` family metadata was promoted
to stable `0.4.0`, local release proof passed through the shared verifier, and
the three packages published to pub.dev in dependency order on 2026-06-24:
`dnd_kit 0.4.0` -> `dnd_kit_flutter 0.4.0` -> `dnd_kit_jaspr 0.4.0`.
+Phase 32 then closed the post-release docs/adoption gap: `US-080` gave the
+website a real Getting Started docs page on its own statically generated `/docs`
+route (replacing the `#docs` placeholder) and aligned the root README plus the
+Jaspr and Flutter package READMEs with the shipped stable `0.4.0` family, and
+`US-081` expanded `/docs` into a multi-page docs section — a shared `DocsShell`
+(grouped sidebar, right-rail TOC, prev/next pager), a Flutter|Jaspr code-tab
+block, and nine core pages over the shared engine surface, and `US-082`
+completed the coverage with four more Concepts pages (collision, sensors,
+modifiers, auto-scroll), an API Reference page, and a collapsible mobile docs
+menu.
Future work should keep extending this roadmap through new product docs, story
packets, and decisions rather than by reviving the old umbrella/core topology
from the historical specs.
diff --git a/docs/stories/phase-32-website-docs-page-and-doc-alignment/README.md b/docs/stories/phase-32-website-docs-page-and-doc-alignment/README.md
new file mode 100644
index 0000000..c35d108
--- /dev/null
+++ b/docs/stories/phase-32-website-docs-page-and-doc-alignment/README.md
@@ -0,0 +1,54 @@
+# Phase 32 - Website Docs Page And Post-0.4.0 Doc Alignment
+
+The coordinated family stable `0.4.0` published on 2026-06-24 (Phase 31), but
+public-facing docs still described the pre-release state: the website had only a
+`#docs` placeholder, the root README status stopped at `US-076`, and the Jaspr
+and Flutter package READMEs still carried "dev release" wording.
+
+This phase closes that adoption gap across two normal-lane stories:
+
+- `US-080` gave the website a real Getting Started docs page on its own `/docs`
+ route (the marketing site was previously a single page with an in-page
+ `#docs` anchor placeholder) and aligned the root README plus the Jaspr and
+ Flutter package READMEs with the shipped stable `0.4.0` family;
+- `US-081` then expanded `/docs` from that single page into a dndkit.com-style
+ multi-page docs section: a shared `DocsShell` (grouped sidebar, right-rail
+ "On this page" TOC, previous/next pager), a Flutter|Jaspr `CodeTabs` block,
+ and nine core pages (Overview, Installation, Quickstart, Draggable,
+ Droppable, Drag overlay, Sortable lists, Multi-container, Accessibility);
+- `US-082` completed the docs coverage: four more Concepts pages (Collision
+ detection, Sensors & activation, Modifiers, Auto-scroll) written against the
+ real engine API, an API Reference page (dartdoc + changelog links), and a
+ collapsible mobile docs menu so the section is navigable below the `lg`
+ breakpoint.
+
+## Principle
+
+Doc-alignment and website work in this phase must:
+
+- introduce real on-site routing rather than another in-page anchor: the docs
+ page is a separate statically generated route (`docs/index.html`), reached
+ from the nav and footer;
+- keep the static build deployable under the `/dnd_kit/` project Pages subpath —
+ every generated page needs its `` rewritten, and cross-page links
+ resolve relative to that base;
+- describe only the already-shipped `0.4.0` surface; no new product behavior.
+
+## Delivery Sequence
+
+| Story | Scope | Decision |
+| --- | --- | --- |
+| **US-080** | Add the website `/docs` Getting Started page (jaspr_router) and align root/package READMEs + website docs link with stable `0.4.0` | No ADR (docs/adoption work under the existing website and package-topology decisions) |
+| **US-081** | Expand `/docs` into a multi-page docs section: shared `DocsShell` (sidebar + TOC + pager), Flutter\|Jaspr `CodeTabs`, nine core pages | No ADR (unified single docs tree with per-snippet adapter tabs, under the existing website decisions) |
+| **US-082** | Complete docs coverage: four Concepts pages (collision, sensors, modifiers, auto-scroll), an API Reference page, and a mobile docs menu | No ADR (docs content + responsive nav under the existing website decisions) |
+
+## Validation Ladder
+
+- Static-site proof: `tool/styles.sh --minify` plus
+ `dart pub global run jaspr_cli:jaspr build` generate both the `/` and `/docs`
+ routes, and `website/build/jaspr/docs/index.html` is present with the
+ per-route `
` and relative asset URLs.
+- Analyzer/format proof: `fvm dart analyze` and `fvm dart format` stay clean for
+ the `website` package.
+- Deploy proof: the deploy workflow rewrites `` on every generated
+ HTML file and verifies both `index.html` and `docs/index.html`.
diff --git a/packages/dnd_kit_flutter/README.md b/packages/dnd_kit_flutter/README.md
index abf5273..4d099c6 100644
--- a/packages/dnd_kit_flutter/README.md
+++ b/packages/dnd_kit_flutter/README.md
@@ -154,5 +154,5 @@ drag runtime.
| Package | Use it for |
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| [`dnd_kit_flutter`](https://pub.dev/packages/dnd_kit_flutter) | Flutter apps — widgets, sensors, overlays, and sortable presets. |
-| [`dnd_kit_jaspr`](https://pub.dev/packages/dnd_kit_jaspr) | Jaspr (Dart web) apps — the current dev adapter release. |
+| [`dnd_kit_jaspr`](https://pub.dev/packages/dnd_kit_jaspr) | Jaspr (Dart web) apps — the web adapter over the shared engine. |
| [`dnd_kit`](https://pub.dev/packages/dnd_kit) | The shared, framework-agnostic engine. |
diff --git a/packages/dnd_kit_jaspr/README.md b/packages/dnd_kit_jaspr/README.md
index 0344cab..252f887 100644
--- a/packages/dnd_kit_jaspr/README.md
+++ b/packages/dnd_kit_jaspr/README.md
@@ -8,7 +8,7 @@ Flutter behave as peer adapters over one drag runtime — the same domain model,
drag lifecycle, collision logic, modifiers, sortable math, and auto-scroll
curve. It depends only on `dnd_kit` and `jaspr` (no Flutter).
-> Status: current public development release. This package provides `DndScope`,
+> Status: stable `0.4.0` release. This package provides `DndScope`,
> `DndController`, `DndDraggable`, `DndDroppable`, `DndDragHandle`,
> `DndDragOverlay`, `DndAutoScroll`, `DndLiveRegion`, `SortableScope`, and
> `SortableItem` over the shared runtime. Shared modifiers and sortable
diff --git a/pubspec.lock b/pubspec.lock
index dec55b3..92a7d6e 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -371,6 +371,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.23.1"
+ jaspr_router:
+ dependency: transitive
+ description:
+ name: jaspr_router
+ sha256: "43abdfd63c19cae8faac50334c7f3065635bac3e85fd558b6076517978ade64a"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.8.2"
jaspr_test:
dependency: transitive
description:
diff --git a/website/README.md b/website/README.md
index 037f6c8..ff91aa8 100644
--- a/website/README.md
+++ b/website/README.md
@@ -60,4 +60,6 @@ static host (GitHub Pages, Netlify, Cloudflare Pages, …).
- GitHub: https://github.com/iamv4g/dnd_kit
- pub.dev: https://pub.dev/packages/dnd_kit_jaspr
-- Docs: _coming soon_ (currently a `#docs` placeholder in the nav/footer)
+- Docs: the multi-page documentation section under the `/docs` routes
+ (`lib/docs/`), linked from the nav and footer. `lib/docs/docs_nav.dart` is the
+ single source of truth for the sidebar, pager, and routes.
diff --git a/website/lib/app.dart b/website/lib/app.dart
new file mode 100644
index 0000000..91f261c
--- /dev/null
+++ b/website/lib/app.dart
@@ -0,0 +1,104 @@
+import 'package:jaspr/jaspr.dart';
+import 'package:jaspr_router/jaspr_router.dart';
+
+import 'docs/pages/accessibility_page.dart';
+import 'docs/pages/autoscroll_page.dart';
+import 'docs/pages/collision_page.dart';
+import 'docs/pages/draggable_page.dart';
+import 'docs/pages/droppable_page.dart';
+import 'docs/pages/install_page.dart';
+import 'docs/pages/modifiers_page.dart';
+import 'docs/pages/multi_container_page.dart';
+import 'docs/pages/overlay_page.dart';
+import 'docs/pages/overview_page.dart';
+import 'docs/pages/quickstart_page.dart';
+import 'docs/pages/reference_page.dart';
+import 'docs/pages/sensors_page.dart';
+import 'docs/pages/sortable_page.dart';
+import 'site.dart';
+
+/// Top-level routing. In static (SSG) mode jaspr generates one HTML file per
+/// route, so this emits `index.html` (the marketing home) plus a page under
+/// `docs/` for each documentation route.
+class App extends StatelessComponent {
+ const App({super.key});
+
+ @override
+ Component build(BuildContext context) {
+ return Router(
+ routes: [
+ Route(path: '/', builder: (context, state) => const Site()),
+ Route(
+ path: '/docs',
+ title: 'Documentation · dnd_kit',
+ builder: (context, state) => const OverviewPage(),
+ ),
+ Route(
+ path: '/docs/install',
+ title: 'Installation · dnd_kit',
+ builder: (context, state) => const InstallPage(),
+ ),
+ Route(
+ path: '/docs/quickstart',
+ title: 'Quickstart · dnd_kit',
+ builder: (context, state) => const QuickstartPage(),
+ ),
+ Route(
+ path: '/docs/draggable',
+ title: 'Draggable · dnd_kit',
+ builder: (context, state) => const DraggablePage(),
+ ),
+ Route(
+ path: '/docs/droppable',
+ title: 'Droppable · dnd_kit',
+ builder: (context, state) => const DroppablePage(),
+ ),
+ Route(
+ path: '/docs/overlay',
+ title: 'Drag overlay · dnd_kit',
+ builder: (context, state) => const OverlayPage(),
+ ),
+ Route(
+ path: '/docs/collision',
+ title: 'Collision detection · dnd_kit',
+ builder: (context, state) => const CollisionPage(),
+ ),
+ Route(
+ path: '/docs/sensors',
+ title: 'Sensors & activation · dnd_kit',
+ builder: (context, state) => const SensorsPage(),
+ ),
+ Route(
+ path: '/docs/modifiers',
+ title: 'Modifiers · dnd_kit',
+ builder: (context, state) => const ModifiersPage(),
+ ),
+ Route(
+ path: '/docs/auto-scroll',
+ title: 'Auto-scroll · dnd_kit',
+ builder: (context, state) => const AutoscrollPage(),
+ ),
+ Route(
+ path: '/docs/sortable',
+ title: 'Sortable lists · dnd_kit',
+ builder: (context, state) => const SortablePage(),
+ ),
+ Route(
+ path: '/docs/multi-container',
+ title: 'Multi-container sortable · dnd_kit',
+ builder: (context, state) => const MultiContainerPage(),
+ ),
+ Route(
+ path: '/docs/accessibility',
+ title: 'Accessibility · dnd_kit',
+ builder: (context, state) => const AccessibilityPage(),
+ ),
+ Route(
+ path: '/docs/reference',
+ title: 'API reference · dnd_kit',
+ builder: (context, state) => const ReferencePage(),
+ ),
+ ],
+ );
+ }
+}
diff --git a/website/lib/data/site_data.dart b/website/lib/data/site_data.dart
index 8bdac38..ba22082 100644
--- a/website/lib/data/site_data.dart
+++ b/website/lib/data/site_data.dart
@@ -11,8 +11,10 @@ class SiteLinks {
static const pubFlutter = 'https://pub.dev/packages/dnd_kit_flutter';
static const pubJaspr = 'https://pub.dev/packages/dnd_kit_jaspr';
- /// Docs site is built later; placeholder for now.
- static const docs = '#docs';
+ /// The on-site Getting Started docs page (`/docs` route). Written relative so
+ /// it resolves against the document `` on both the project Pages
+ /// subpath (`/dnd_kit/docs/`) and a local server root (`/docs/`).
+ static const docs = 'docs/';
}
/// In-page nav targets (also the reorderable nav pills).
diff --git a/website/lib/docs/code_tabs.dart b/website/lib/docs/code_tabs.dart
new file mode 100644
index 0000000..177a279
--- /dev/null
+++ b/website/lib/docs/code_tabs.dart
@@ -0,0 +1,75 @@
+import 'package:jaspr/dom.dart';
+import 'package:jaspr/jaspr.dart';
+
+/// A code block with a Flutter / Jaspr toggle, so one snippet shows the same
+/// API on both adapters. Server-rendered with the Flutter tab active, then
+/// hydrated to switch tabs on the client.
+@client
+class CodeTabs extends StatefulComponent {
+ const CodeTabs({
+ required this.flutter,
+ required this.jaspr,
+ this.flutterFile = 'main.dart',
+ this.jasprFile = 'main.dart',
+ super.key,
+ });
+
+ final String flutter;
+ final String jaspr;
+ final String flutterFile;
+ final String jasprFile;
+
+ @override
+ State createState() => _CodeTabsState();
+}
+
+class _CodeTabsState extends State {
+ int _tab = 0; // 0 = Flutter, 1 = Jaspr
+
+ @override
+ Component build(BuildContext context) {
+ final tabs = ['Flutter', 'Jaspr'];
+ final code = _tab == 0 ? component.flutter : component.jaspr;
+ final file = _tab == 0 ? component.flutterFile : component.jasprFile;
+ return div(
+ classes:
+ 'overflow-hidden rounded-2xl border border-line bg-surface shadow-lift',
+ [
+ div(
+ classes:
+ 'flex items-center gap-3 border-b border-line bg-raised px-4 py-2.5',
+ [
+ div(
+ classes: 'flex items-center gap-1',
+ attributes: const {'role': 'tablist'},
+ [
+ for (var i = 0; i < tabs.length; i++)
+ button(
+ classes:
+ 'rounded-full px-3 py-1 font-mono text-xs transition-colors '
+ '${i == _tab ? 'bg-accent text-white' : 'text-muted hover:text-ink'}',
+ attributes: {
+ 'type': 'button',
+ 'role': 'tab',
+ 'aria-selected': (i == _tab).toString(),
+ },
+ onClick: () => setState(() => _tab = i),
+ [.text(tabs[i])],
+ ),
+ ],
+ ),
+ span(classes: 'ml-auto font-mono text-xs text-muted', [
+ .text(file),
+ ]),
+ ],
+ ),
+ Component.element(
+ tag: 'pre',
+ classes:
+ 'overflow-x-auto p-5 font-mono text-sm leading-relaxed text-ink',
+ children: [.text(code)],
+ ),
+ ],
+ );
+ }
+}
diff --git a/website/lib/docs/doc_components.dart b/website/lib/docs/doc_components.dart
new file mode 100644
index 0000000..281e195
--- /dev/null
+++ b/website/lib/docs/doc_components.dart
@@ -0,0 +1,142 @@
+import 'package:jaspr/dom.dart';
+import 'package:jaspr/jaspr.dart';
+
+/// Shared building blocks for documentation pages: the lead paragraph, the
+/// "You'll learn" callout, section wrappers, prose, bullet lists, inline code,
+/// and the "Next steps" card grid.
+
+const _prose = 'max-w-3xl leading-relaxed text-muted';
+
+/// The one-sentence summary directly under a page heading.
+Component docLead(String text) {
+ return p(classes: 'max-w-3xl text-lg leading-relaxed text-muted', [
+ .text(text),
+ ]);
+}
+
+/// A short outcomes callout shown near the top of a page.
+Component youWillLearn(List items) {
+ return div(
+ classes: 'max-w-3xl rounded-2xl border border-line bg-surface p-5',
+ [
+ span(
+ classes: 'font-mono text-xs uppercase tracking-[0.18em] text-accent',
+ const [.text("You'll learn")],
+ ),
+ ul(classes: 'mt-3 flex flex-col gap-1.5 text-muted', [
+ for (final item in items)
+ li(classes: 'flex gap-2', [
+ span(classes: 'text-accent', const [.text('—')]),
+ span([.text(item)]),
+ ]),
+ ]),
+ ],
+ );
+}
+
+/// A page section with an anchored `` heading.
+Component docSection({
+ required String id,
+ required String title,
+ required List children,
+}) {
+ return section(id: id, classes: 'scroll-mt-24', [
+ h2(classes: 'font-serif text-2xl text-ink sm:text-3xl', [.text(title)]),
+ div(classes: 'mt-4 flex flex-col gap-4', children),
+ ]);
+}
+
+/// A prose paragraph. [spans] lets a paragraph mix text with inline code.
+Component docProse(String text) => p(classes: _prose, [.text(text)]);
+
+/// A prose paragraph built from mixed inline content (text + inlineCode).
+Component docProseRich(List spans) => p(classes: _prose, spans);
+
+/// Plain inline text node, for composing with [inlineCode] in [docProseRich].
+Component docText(String text) => Component.text(text);
+
+/// Inline `code` styling.
+Component inlineCode(String text) {
+ return Component.element(
+ tag: 'code',
+ classes: 'rounded bg-raised px-1.5 py-0.5 font-mono text-[0.85em] text-ink',
+ children: [.text(text)],
+ );
+}
+
+/// A plain (non-tabbed) code block with a filename label.
+Component docCodeBlock(String filename, String code) {
+ return div(
+ classes:
+ 'overflow-hidden rounded-2xl border border-line bg-surface shadow-lift',
+ [
+ div(
+ classes:
+ 'flex items-center gap-3 border-b border-line bg-raised px-4 py-2.5',
+ [
+ div(classes: 'flex items-center gap-2', [
+ span(classes: 'h-2.5 w-2.5 rounded-full bg-accent/70', const []),
+ span(classes: 'h-2.5 w-2.5 rounded-full bg-muted/40', const []),
+ span(classes: 'h-2.5 w-2.5 rounded-full bg-muted/40', const []),
+ ]),
+ span(classes: 'ml-auto font-mono text-xs text-muted', [
+ .text(filename),
+ ]),
+ ],
+ ),
+ Component.element(
+ tag: 'pre',
+ classes:
+ 'overflow-x-auto p-5 font-mono text-sm leading-relaxed text-ink',
+ children: [.text(code)],
+ ),
+ ],
+ );
+}
+
+/// An unordered list of prose items.
+Component docBullets(List items) {
+ return ul(classes: 'flex max-w-3xl flex-col gap-2 text-muted', [
+ for (final item in items)
+ li(classes: 'flex gap-2', [
+ span(classes: 'mt-0.5 text-accent', const [.text('•')]),
+ span([.text(item)]),
+ ]),
+ ]);
+}
+
+/// A "Next steps" card linking to another doc page or external resource.
+class NextStep {
+ const NextStep({
+ required this.label,
+ required this.desc,
+ required this.href,
+ this.external = false,
+ });
+
+ final String label;
+ final String desc;
+ final String href;
+ final bool external;
+}
+
+/// The "Next steps" card grid shown at the bottom of a page body.
+Component nextSteps(List steps) {
+ return div(classes: 'mt-2 grid gap-3 sm:grid-cols-2', [
+ for (final step in steps)
+ a(
+ href: step.href,
+ target: step.external ? Target.blank : null,
+ attributes: step.external ? const {'rel': 'noreferrer'} : null,
+ classes:
+ 'flex flex-col gap-1 rounded-2xl border border-line bg-surface p-4 '
+ 'transition-colors hover:border-accent',
+ [
+ span(classes: 'font-semibold text-ink', [
+ .text(step.external ? '${step.label} ↗' : step.label),
+ ]),
+ span(classes: 'text-sm text-muted', [.text(step.desc)]),
+ ],
+ ),
+ ]);
+}
diff --git a/website/lib/docs/docs_nav.dart b/website/lib/docs/docs_nav.dart
new file mode 100644
index 0000000..8559768
--- /dev/null
+++ b/website/lib/docs/docs_nav.dart
@@ -0,0 +1,178 @@
+/// The documentation information architecture: grouped sidebar entries plus a
+/// flat ordered list that drives the previous/next pager. This is the single
+/// source of truth for the docs section — pages, routes, and the sidebar all
+/// read from it.
+library;
+
+/// One documentation page.
+class DocEntry {
+ const DocEntry({
+ required this.slug,
+ required this.navLabel,
+ required this.title,
+ required this.group,
+ });
+
+ /// URL slug under `/docs`. Empty for the docs landing (`/docs`).
+ final String slug;
+
+ /// Short label shown in the sidebar.
+ final String navLabel;
+
+ /// Page heading (``).
+ final String title;
+
+ /// Group label, shown as the eyebrow above the heading.
+ final String group;
+
+ /// jaspr_router path for this page (e.g. `/docs` or `/docs/draggable`).
+ String get routePath => slug.isEmpty ? '/docs' : '/docs/$slug';
+
+ /// Link target, relative to the document ``. Trailing slash keeps
+ /// same-page anchors on the generated `index.html` instead of bouncing
+ /// through a Pages redirect.
+ String get href => slug.isEmpty ? 'docs/' : 'docs/$slug/';
+
+ /// A same-page anchor link to a section [id] on this page.
+ String anchor(String id) => '$href#$id';
+}
+
+/// A labelled group of sidebar entries.
+class DocGroup {
+ const DocGroup({required this.label, required this.entries});
+
+ final String label;
+ final List entries;
+}
+
+const docGroups = [
+ DocGroup(
+ label: 'Get started',
+ entries: [
+ DocEntry(
+ slug: '',
+ navLabel: 'Overview',
+ title: 'dnd_kit documentation',
+ group: 'Get started',
+ ),
+ DocEntry(
+ slug: 'install',
+ navLabel: 'Installation',
+ title: 'Installation',
+ group: 'Get started',
+ ),
+ DocEntry(
+ slug: 'quickstart',
+ navLabel: 'Quickstart',
+ title: 'Quickstart',
+ group: 'Get started',
+ ),
+ ],
+ ),
+ DocGroup(
+ label: 'Concepts',
+ entries: [
+ DocEntry(
+ slug: 'draggable',
+ navLabel: 'Draggable',
+ title: 'Draggable',
+ group: 'Concepts',
+ ),
+ DocEntry(
+ slug: 'droppable',
+ navLabel: 'Droppable',
+ title: 'Droppable',
+ group: 'Concepts',
+ ),
+ DocEntry(
+ slug: 'overlay',
+ navLabel: 'Drag overlay',
+ title: 'Drag overlay',
+ group: 'Concepts',
+ ),
+ DocEntry(
+ slug: 'collision',
+ navLabel: 'Collision detection',
+ title: 'Collision detection',
+ group: 'Concepts',
+ ),
+ DocEntry(
+ slug: 'sensors',
+ navLabel: 'Sensors & activation',
+ title: 'Sensors & activation',
+ group: 'Concepts',
+ ),
+ DocEntry(
+ slug: 'modifiers',
+ navLabel: 'Modifiers',
+ title: 'Modifiers',
+ group: 'Concepts',
+ ),
+ DocEntry(
+ slug: 'auto-scroll',
+ navLabel: 'Auto-scroll',
+ title: 'Auto-scroll',
+ group: 'Concepts',
+ ),
+ ],
+ ),
+ DocGroup(
+ label: 'Sortable',
+ entries: [
+ DocEntry(
+ slug: 'sortable',
+ navLabel: 'Sortable lists',
+ title: 'Sortable lists',
+ group: 'Sortable',
+ ),
+ DocEntry(
+ slug: 'multi-container',
+ navLabel: 'Multi-container',
+ title: 'Multi-container sortable',
+ group: 'Sortable',
+ ),
+ ],
+ ),
+ DocGroup(
+ label: 'Accessibility',
+ entries: [
+ DocEntry(
+ slug: 'accessibility',
+ navLabel: 'Accessibility',
+ title: 'Accessibility',
+ group: 'Accessibility',
+ ),
+ ],
+ ),
+ DocGroup(
+ label: 'Reference',
+ entries: [
+ DocEntry(
+ slug: 'reference',
+ navLabel: 'API reference',
+ title: 'API reference',
+ group: 'Reference',
+ ),
+ ],
+ ),
+];
+
+/// All entries in sidebar order — drives the previous/next pager.
+final List docOrder = [
+ for (final group in docGroups) ...group.entries,
+];
+
+/// The entry immediately before [slug] in reading order, or null.
+DocEntry? docPrev(String slug) {
+ final i = docOrder.indexWhere((e) => e.slug == slug);
+ return i > 0 ? docOrder[i - 1] : null;
+}
+
+/// The entry immediately after [slug] in reading order, or null.
+DocEntry? docNext(String slug) {
+ final i = docOrder.indexWhere((e) => e.slug == slug);
+ return i >= 0 && i < docOrder.length - 1 ? docOrder[i + 1] : null;
+}
+
+/// The base-relative link for a page [slug] (for cross-page references).
+String docHref(String slug) => docOrder.firstWhere((e) => e.slug == slug).href;
diff --git a/website/lib/docs/docs_shell.dart b/website/lib/docs/docs_shell.dart
new file mode 100644
index 0000000..f35061d
--- /dev/null
+++ b/website/lib/docs/docs_shell.dart
@@ -0,0 +1,200 @@
+import 'package:jaspr/dom.dart';
+import 'package:jaspr/jaspr.dart';
+
+import '../components/ui.dart';
+import '../layout/footer.dart';
+import '../layout/nav_bar.dart';
+import 'docs_nav.dart';
+
+/// The shared documentation chrome: top nav, a grouped left sidebar, the page
+/// body, a right-rail "On this page" table of contents, and a previous/next
+/// pager. Every docs page wraps its content in this shell.
+class DocsShell extends StatelessComponent {
+ const DocsShell({
+ required this.slug,
+ required this.toc,
+ required this.body,
+ super.key,
+ });
+
+ /// The current page's slug (matches a [DocEntry.slug]).
+ final String slug;
+
+ /// Right-rail anchors for the sections on this page.
+ final List<({String id, String label})> toc;
+
+ /// The page body — lead, callout, and sections.
+ final List body;
+
+ DocEntry get _entry => docOrder.firstWhere((e) => e.slug == slug);
+
+ @override
+ Component build(BuildContext context) {
+ final entry = _entry;
+ return .fragment([
+ div(id: 'top', const []),
+ const NavBar(activeDocs: true),
+ Component.element(
+ tag: 'main',
+ children: [
+ div(
+ classes:
+ 'mx-auto max-w-7xl gap-10 px-6 py-10 '
+ 'lg:grid lg:grid-cols-[14rem_minmax(0,1fr)] '
+ 'xl:grid-cols-[14rem_minmax(0,1fr)_13rem]',
+ [_sidebar(), _content(entry), _tocRail()],
+ ),
+ ],
+ ),
+ const Footer(),
+ ]);
+ }
+
+ Component _sidebar() {
+ return Component.element(
+ tag: 'aside',
+ classes: 'hidden lg:block',
+ children: [
+ nav(
+ classes: 'sticky top-24 flex flex-col gap-6',
+ attributes: const {'aria-label': 'Documentation'},
+ [
+ for (final group in docGroups)
+ div(classes: 'flex flex-col gap-1', [
+ span(
+ classes:
+ 'mb-1 font-mono text-xs uppercase tracking-[0.18em] '
+ 'text-muted',
+ [.text(group.label)],
+ ),
+ for (final entry in group.entries) _sidebarLink(entry),
+ ]),
+ ],
+ ),
+ ],
+ );
+ }
+
+ Component _sidebarLink(DocEntry entry) {
+ final active = entry.slug == slug;
+ return a(
+ href: entry.href,
+ attributes: active ? const {'aria-current': 'page'} : null,
+ classes:
+ 'rounded-lg px-3 py-1.5 text-sm transition-colors '
+ '${active ? 'bg-surface font-medium text-accent' : 'text-muted hover:bg-surface hover:text-ink'}',
+ [.text(entry.navLabel)],
+ );
+ }
+
+ /// A collapsible group menu shown below the `lg` breakpoint, where the
+ /// fixed sidebar is hidden. Native ``, so it needs no hydration.
+ Component _mobileNav() {
+ return Component.element(
+ tag: 'details',
+ classes:
+ 'sticky top-16 z-20 mb-8 rounded-2xl border border-line '
+ 'bg-paper/95 shadow-sm backdrop-blur lg:hidden',
+ children: [
+ Component.element(
+ tag: 'summary',
+ classes:
+ 'cursor-pointer select-none rounded-2xl px-4 py-3 text-sm '
+ 'font-medium text-ink',
+ children: const [.text('Documentation menu')],
+ ),
+ div(
+ classes:
+ 'flex max-h-[70vh] flex-col gap-5 overflow-auto border-t '
+ 'border-line px-4 py-4',
+ [
+ for (final group in docGroups)
+ div(classes: 'flex flex-col gap-1', [
+ span(
+ classes:
+ 'mb-1 font-mono text-xs uppercase tracking-[0.18em] '
+ 'text-muted',
+ [.text(group.label)],
+ ),
+ for (final entry in group.entries) _sidebarLink(entry),
+ ]),
+ ],
+ ),
+ ],
+ );
+ }
+
+ Component _content(DocEntry entry) {
+ return div(classes: 'min-w-0', [
+ _mobileNav(),
+ eyebrow(entry.group),
+ h1(classes: 'mt-3 font-serif text-4xl text-ink sm:text-5xl', [
+ .text(entry.title),
+ ]),
+ div(classes: 'mt-6 flex flex-col gap-10', body),
+ _pager(),
+ ]);
+ }
+
+ Component _tocRail() {
+ if (toc.isEmpty) return Component.element(tag: 'div', children: const []);
+ return Component.element(
+ tag: 'aside',
+ classes: 'hidden xl:block',
+ children: [
+ nav(
+ classes: 'sticky top-24 flex flex-col gap-2',
+ attributes: const {'aria-label': 'On this page'},
+ [
+ span(
+ classes:
+ 'font-mono text-xs uppercase tracking-[0.18em] text-muted',
+ const [.text('On this page')],
+ ),
+ for (final item in toc)
+ a(
+ href: _entry.anchor(item.id),
+ classes: 'text-sm text-muted transition-colors hover:text-ink',
+ [.text(item.label)],
+ ),
+ ],
+ ),
+ ],
+ );
+ }
+
+ Component _pager() {
+ final prev = docPrev(slug);
+ final next = docNext(slug);
+ if (prev == null && next == null) {
+ return Component.element(tag: 'div', children: const []);
+ }
+ return div(
+ classes:
+ 'mt-12 flex items-stretch justify-between gap-4 border-t '
+ 'border-line pt-6',
+ [
+ if (prev != null) _pagerLink(prev, next: false) else div(const []),
+ if (next != null) _pagerLink(next, next: true) else div(const []),
+ ],
+ );
+ }
+
+ Component _pagerLink(DocEntry entry, {required bool next}) {
+ return a(
+ href: entry.href,
+ classes:
+ 'flex flex-col gap-0.5 rounded-2xl border border-line bg-surface '
+ 'px-5 py-3 transition-colors hover:border-accent '
+ '${next ? 'items-end text-right' : 'items-start'}',
+ [
+ span(classes: 'font-mono text-xs text-muted', [
+ .text(next ? 'Next' : 'Previous'),
+ ]),
+ span(classes: 'font-medium text-ink', [
+ .text(next ? '${entry.navLabel} →' : '← ${entry.navLabel}'),
+ ]),
+ ],
+ );
+ }
+}
diff --git a/website/lib/docs/pages/accessibility_page.dart b/website/lib/docs/pages/accessibility_page.dart
new file mode 100644
index 0000000..19f664e
--- /dev/null
+++ b/website/lib/docs/pages/accessibility_page.dart
@@ -0,0 +1,115 @@
+import 'package:jaspr/jaspr.dart';
+
+import '../code_tabs.dart';
+import '../doc_components.dart';
+import '../docs_nav.dart';
+import '../docs_shell.dart';
+
+/// `/docs/accessibility`
+class AccessibilityPage extends StatelessComponent {
+ const AccessibilityPage({super.key});
+
+ @override
+ Component build(BuildContext context) {
+ return DocsShell(
+ slug: 'accessibility',
+ toc: const [
+ (id: 'keyboard', label: 'Keyboard'),
+ (id: 'announcements', label: 'Announcements'),
+ (id: 'handles', label: 'Handles & labels'),
+ ],
+ body: [
+ docLead(
+ 'Accessibility is built in, not bolted on. Every draggable is '
+ 'operable from the keyboard, and a live region narrates the drag so '
+ 'screen-reader users follow the same interaction as everyone else.',
+ ),
+ youWillLearn(const [
+ 'How keyboard dragging works out of the box.',
+ 'How drag progress is announced.',
+ 'How to label drag handles for assistive tech.',
+ ]),
+ docSection(
+ id: 'keyboard',
+ title: 'Keyboard',
+ children: [
+ docProse(
+ 'Draggables are focusable and operable without a pointer. The '
+ 'keyboard sensor is active by default:',
+ ),
+ docBullets(const [
+ 'Space or Enter — pick up the focused item, and drop it.',
+ 'Arrow keys — move the lifted item between positions or targets.',
+ 'Escape — cancel the drag and return the item to its origin.',
+ ]),
+ ],
+ ),
+ docSection(
+ id: 'announcements',
+ title: 'Announcements',
+ children: [
+ docProseRich([
+ docText(
+ 'Drag progress is announced through a polite live region. On '
+ 'Jaspr, mount a ',
+ ),
+ inlineCode('DndLiveRegion'),
+ docText(
+ ' inside the scope; on Flutter, the adapter emits semantics '
+ 'announcements. Both speak the same shared ',
+ ),
+ inlineCode('DndAnnouncements'),
+ docText(' contract — pick up, move, drop, and cancel.'),
+ ]),
+ const CodeTabs(
+ flutterFile: 'a11y.dart',
+ jasprFile: 'a11y.dart',
+ flutter: _flutter,
+ jaspr: _jaspr,
+ ),
+ ],
+ ),
+ docSection(
+ id: 'handles',
+ title: 'Handles & labels',
+ children: [
+ docProseRich([
+ docText('Give every '),
+ inlineCode('DndDragHandle'),
+ docText(' a descriptive '),
+ inlineCode('label'),
+ docText(
+ ' so assistive technology announces what it moves — for '
+ 'example "Reorder card: Buy milk" rather than an unlabeled grip.',
+ ),
+ ]),
+ ],
+ ),
+ nextSteps([
+ NextStep(
+ label: 'Sortable lists',
+ desc: 'Keyboard-accessible reordering by default.',
+ href: docHref('sortable'),
+ ),
+ NextStep(
+ label: 'Overview',
+ desc: 'Back to the documentation home.',
+ href: docHref(''),
+ ),
+ ]),
+ ],
+ );
+ }
+}
+
+const _flutter = '''DndScope(
+ // Flutter: announcements are emitted as semantics events automatically.
+ child: board,
+)''';
+
+const _jaspr = '''DndScope(
+ child: div([
+ board,
+ const DndLiveRegion(), // polite live region for screen readers
+ ]),
+)''';
diff --git a/website/lib/docs/pages/autoscroll_page.dart b/website/lib/docs/pages/autoscroll_page.dart
new file mode 100644
index 0000000..e33f37e
--- /dev/null
+++ b/website/lib/docs/pages/autoscroll_page.dart
@@ -0,0 +1,107 @@
+import 'package:jaspr/jaspr.dart';
+
+import '../code_tabs.dart';
+import '../doc_components.dart';
+import '../docs_nav.dart';
+import '../docs_shell.dart';
+
+/// `/docs/auto-scroll`
+class AutoscrollPage extends StatelessComponent {
+ const AutoscrollPage({super.key});
+
+ @override
+ Component build(BuildContext context) {
+ return DocsShell(
+ slug: 'auto-scroll',
+ toc: const [
+ (id: 'how', label: 'How it works'),
+ (id: 'usage', label: 'Usage'),
+ (id: 'options', label: 'Options'),
+ ],
+ body: [
+ docLead(
+ 'When a drag reaches the edge of a scrollable region, auto-scroll '
+ 'moves the content to follow — so you can drag into parts of a list '
+ 'or board that are off-screen.',
+ ),
+ youWillLearn(const [
+ 'How edge auto-scroll is driven.',
+ 'How to wrap a scrollable region with DndAutoScroll.',
+ 'Which axis and options you can tune.',
+ ]),
+ docSection(
+ id: 'how',
+ title: 'How it works',
+ children: [
+ docProse(
+ 'The scroll velocity comes from the same DOM-free math on both '
+ 'adapters: the closer the pointer is to an edge, the faster the '
+ 'region scrolls. Only the execution differs — Flutter drives a '
+ 'Scrollable, Jaspr scrolls a DOM element.',
+ ),
+ ],
+ ),
+ docSection(
+ id: 'usage',
+ title: 'Usage',
+ children: [
+ docProseRich([
+ docText('Wrap the scrollable area in a '),
+ inlineCode('DndAutoScroll'),
+ docText(' inside the scope. On Flutter it drives the nearest '),
+ inlineCode('Scrollable'),
+ docText('; on Jaspr you make the wrapper scroll with '),
+ inlineCode('overflow'),
+ docText(' styles or classes.'),
+ ]),
+ const CodeTabs(
+ flutterFile: 'auto_scroll.dart',
+ jasprFile: 'auto_scroll.dart',
+ flutter: _flutterCode,
+ jaspr: _jasprCode,
+ ),
+ ],
+ ),
+ docSection(
+ id: 'options',
+ title: 'Options',
+ children: [
+ docBullets(const [
+ 'axis — DndScrollAxis.vertical (default) or horizontal.',
+ 'enabled — turn auto-scroll off without unwrapping.',
+ 'options — a DndAutoScrollOptions to tune edge thresholds and '
+ 'speed.',
+ ]),
+ ],
+ ),
+ nextSteps([
+ NextStep(
+ label: 'Sortable lists',
+ desc: 'Reorder long lists that scroll while dragging.',
+ href: docHref('sortable'),
+ ),
+ NextStep(
+ label: 'Multi-container sortable',
+ desc: 'Auto-scroll columns on a Kanban board.',
+ href: docHref('multi-container'),
+ ),
+ ]),
+ ],
+ );
+ }
+}
+
+const _flutterCode = '''DndScope(
+ child: DndAutoScroll(
+ axis: DndScrollAxis.vertical,
+ child: ListView(children: cards),
+ ),
+)''';
+
+const _jasprCode = '''DndScope(
+ child: DndAutoScroll(
+ axis: DndScrollAxis.vertical,
+ classes: 'max-h-[480px] overflow-auto',
+ child: div(cards),
+ ),
+)''';
diff --git a/website/lib/docs/pages/collision_page.dart b/website/lib/docs/pages/collision_page.dart
new file mode 100644
index 0000000..756fbe9
--- /dev/null
+++ b/website/lib/docs/pages/collision_page.dart
@@ -0,0 +1,124 @@
+import 'package:jaspr/jaspr.dart';
+
+import '../code_tabs.dart';
+import '../doc_components.dart';
+import '../docs_nav.dart';
+import '../docs_shell.dart';
+
+/// `/docs/collision`
+class CollisionPage extends StatelessComponent {
+ const CollisionPage({super.key});
+
+ @override
+ Component build(BuildContext context) {
+ return DocsShell(
+ slug: 'collision',
+ toc: const [
+ (id: 'detectors', label: 'Built-in detectors'),
+ (id: 'setting', label: 'Setting a detector'),
+ (id: 'composing', label: 'Composing'),
+ ],
+ body: [
+ docLead(
+ 'When a draggable overlaps several droppables, a collision detector '
+ 'decides which one wins. Detection is shared math in the engine, so '
+ 'Flutter and Jaspr resolve the same target from the same input.',
+ ),
+ youWillLearn(const [
+ 'The built-in collision detectors and when to use each.',
+ 'How to set a detector on a scope.',
+ 'How to combine detectors into a fallback chain.',
+ ]),
+ docSection(
+ id: 'detectors',
+ title: 'Built-in detectors',
+ children: [
+ docProseRich([
+ docText('All detectors live on '),
+ inlineCode('DndCollisionDetectors'),
+ docText(':'),
+ ]),
+ docBullets(const [
+ 'closestCenter — the target whose center is nearest the pointer. '
+ 'A solid default for grids and free layouts.',
+ 'closestCorners — compares corners instead of centers; steadier '
+ 'when targets vary a lot in size.',
+ 'rectIntersection — the target the dragged rect overlaps most. '
+ 'Natural for list and Kanban reordering.',
+ 'pointerWithin — only targets the pointer is literally inside. '
+ 'Precise, but needs a pointer (not keyboard) drag.',
+ ]),
+ ],
+ ),
+ docSection(
+ id: 'setting',
+ title: 'Setting a detector',
+ children: [
+ docProseRich([
+ docText('Pass a detector to the '),
+ inlineCode('DndController'),
+ docText(' on your '),
+ inlineCode('DndScope'),
+ docText('. The default is '),
+ inlineCode('closestCenter'),
+ docText('.'),
+ ]),
+ const CodeTabs(
+ flutterFile: 'scope.dart',
+ jasprFile: 'scope.dart',
+ flutter: _setCode,
+ jaspr: _setCode,
+ ),
+ ],
+ ),
+ docSection(
+ id: 'composing',
+ title: 'Composing',
+ children: [
+ docProseRich([
+ docText('Use '),
+ inlineCode('compose'),
+ docText(
+ ' to try detectors in order and take the first that finds a '
+ 'target — for example, prefer a precise pointer hit, then fall '
+ 'back to overlap.',
+ ),
+ ]),
+ const CodeTabs(
+ flutterFile: 'scope.dart',
+ jasprFile: 'scope.dart',
+ flutter: _composeCode,
+ jaspr: _composeCode,
+ ),
+ ],
+ ),
+ nextSteps([
+ NextStep(
+ label: 'Sensors & activation',
+ desc: 'Choose input methods and require intent.',
+ href: docHref('sensors'),
+ ),
+ NextStep(
+ label: 'Modifiers',
+ desc: 'Constrain the drag transform.',
+ href: docHref('modifiers'),
+ ),
+ ]),
+ ],
+ );
+ }
+}
+
+const _setCode = '''DndScope(
+ controller: DndController(
+ collisionDetector: DndCollisionDetectors.closestCenter,
+ ),
+ child: board,
+)''';
+
+const _composeCode = '''DndController(
+ collisionDetector: DndCollisionDetectors.compose([
+ DndCollisionDetectors.pointerWithin,
+ DndCollisionDetectors.rectIntersection,
+ ]),
+)''';
diff --git a/website/lib/docs/pages/draggable_page.dart b/website/lib/docs/pages/draggable_page.dart
new file mode 100644
index 0000000..2fb6aa4
--- /dev/null
+++ b/website/lib/docs/pages/draggable_page.dart
@@ -0,0 +1,172 @@
+import 'package:jaspr/jaspr.dart';
+
+import '../code_tabs.dart';
+import '../doc_components.dart';
+import '../docs_nav.dart';
+import '../docs_shell.dart';
+
+/// `/docs/draggable`
+class DraggablePage extends StatelessComponent {
+ const DraggablePage({super.key});
+
+ @override
+ Component build(BuildContext context) {
+ return DocsShell(
+ slug: 'draggable',
+ toc: const [
+ (id: 'usage', label: 'Usage'),
+ (id: 'handle', label: 'Drag handles'),
+ (id: 'activation', label: 'Activation'),
+ (id: 'events', label: 'Drag events'),
+ ],
+ body: [
+ docLead(
+ 'A draggable is any element that can be picked up and moved. Wrap it '
+ 'in DndDraggable, give it a stable DndId, and the engine handles the '
+ 'pointer, keyboard, and transform.',
+ ),
+ youWillLearn(const [
+ 'How to make an element draggable.',
+ 'How to restrict the drag surface to a handle.',
+ 'How to require intent before a drag begins.',
+ 'Which events fire over a drag lifecycle.',
+ ]),
+ docSection(
+ id: 'usage',
+ title: 'Usage',
+ children: [
+ docProseRich([
+ docText('Every draggable needs a unique '),
+ inlineCode('DndId'),
+ docText(
+ ' within its scope. The id is how collision results and drag '
+ 'events refer back to it.',
+ ),
+ ]),
+ const CodeTabs(
+ flutterFile: 'draggable.dart',
+ jasprFile: 'draggable.dart',
+ flutter: _usageFlutter,
+ jaspr: _usageJaspr,
+ ),
+ ],
+ ),
+ docSection(
+ id: 'handle',
+ title: 'Drag handles',
+ children: [
+ docProseRich([
+ docText('Wrap part of the child in a '),
+ inlineCode('DndDragHandle'),
+ docText(
+ ' to make only that part the drag surface — useful when the '
+ 'rest of the element stays interactive (links, buttons, text '
+ 'selection).',
+ ),
+ ]),
+ const CodeTabs(
+ flutterFile: 'handle.dart',
+ jasprFile: 'handle.dart',
+ flutter: _handleFlutter,
+ jaspr: _handleJaspr,
+ ),
+ ],
+ ),
+ docSection(
+ id: 'activation',
+ title: 'Activation',
+ children: [
+ docProseRich([
+ docText('Pass a '),
+ inlineCode('DndSensorActivationConstraint'),
+ docText(
+ ' so a drag only starts after deliberate intent — a small '
+ 'distance or a press delay — instead of on the first pixel of '
+ 'movement. This keeps taps and clicks responsive.',
+ ),
+ ]),
+ const CodeTabs(
+ flutterFile: 'activation.dart',
+ jasprFile: 'activation.dart',
+ flutter: _activationFlutter,
+ jaspr: _activationJaspr,
+ ),
+ ],
+ ),
+ docSection(
+ id: 'events',
+ title: 'Drag events',
+ children: [
+ docProse('A draggable reports its lifecycle through callbacks:'),
+ docBullets(const [
+ 'onDragStart — the drag was activated and picked up.',
+ 'onDragUpdate — the pointer moved; the active transform changed.',
+ 'onDragEnd — the drag finished; read event.overId for the target '
+ 'it landed on, or handle a cancel.',
+ ]),
+ ],
+ ),
+ nextSteps([
+ NextStep(
+ label: 'Droppable',
+ desc: 'Define the targets a draggable can land on.',
+ href: docHref('droppable'),
+ ),
+ NextStep(
+ label: 'Drag overlay',
+ desc: 'Render a floating preview that follows the pointer.',
+ href: docHref('overlay'),
+ ),
+ ]),
+ ],
+ );
+ }
+}
+
+const _usageFlutter = '''DndDraggable(
+ id: const DndId('card-1'),
+ data: card, // optional payload carried through the drag
+ child: CardTile(card),
+)''';
+
+const _usageJaspr = '''DndDraggable(
+ id: const DndId('card-1'),
+ data: card, // optional payload carried through the drag
+ child: cardTile(card),
+)''';
+
+const _handleFlutter = '''DndDraggable(
+ id: const DndId('card-1'),
+ child: Row(
+ children: [
+ DndDragHandle(
+ label: 'Reorder card',
+ child: const Icon(Icons.drag_indicator),
+ ),
+ const Expanded(child: Text('Card title')),
+ ],
+ ),
+)''';
+
+const _handleJaspr = '''DndDraggable(
+ id: const DndId('card-1'),
+ child: div([
+ DndDragHandle(
+ label: 'Reorder card',
+ child: span([.text('⠿')]),
+ ),
+ span([.text('Card title')]),
+ ]),
+)''';
+
+const _activationFlutter = '''DndDraggable(
+ id: const DndId('card-1'),
+ constraint: const DndSensorActivationConstraint(distance: 6),
+ child: CardTile(card),
+)''';
+
+const _activationJaspr = '''DndDraggable(
+ id: const DndId('card-1'),
+ constraint: const DndSensorActivationConstraint(distance: 6),
+ child: cardTile(card),
+)''';
diff --git a/website/lib/docs/pages/droppable_page.dart b/website/lib/docs/pages/droppable_page.dart
new file mode 100644
index 0000000..4066757
--- /dev/null
+++ b/website/lib/docs/pages/droppable_page.dart
@@ -0,0 +1,133 @@
+import 'package:jaspr/jaspr.dart';
+
+import '../code_tabs.dart';
+import '../doc_components.dart';
+import '../docs_nav.dart';
+import '../docs_shell.dart';
+
+/// `/docs/droppable`
+class DroppablePage extends StatelessComponent {
+ const DroppablePage({super.key});
+
+ @override
+ Component build(BuildContext context) {
+ return DocsShell(
+ slug: 'droppable',
+ toc: const [
+ (id: 'usage', label: 'Usage'),
+ (id: 'collision', label: 'Collision detection'),
+ (id: 'feedback', label: 'Hover feedback'),
+ ],
+ body: [
+ docLead(
+ 'A droppable is a target a draggable can land on. Mark an area with '
+ 'DndDroppable and a unique id; the engine reports when a draggable '
+ 'is over it and which target a drag ends on.',
+ ),
+ youWillLearn(const [
+ 'How to define a drop target.',
+ 'How the engine decides which target is under the pointer.',
+ 'How to give visual feedback while a draggable hovers.',
+ ]),
+ docSection(
+ id: 'usage',
+ title: 'Usage',
+ children: [
+ docProseRich([
+ docText('Give each droppable a unique '),
+ inlineCode('DndId'),
+ docText('. On the draggable, '),
+ inlineCode('onDragEnd'),
+ docText(' reports the target id through '),
+ inlineCode('event.overId'),
+ docText('.'),
+ ]),
+ const CodeTabs(
+ flutterFile: 'droppable.dart',
+ jasprFile: 'droppable.dart',
+ flutter: _usageFlutter,
+ jaspr: _usageJaspr,
+ ),
+ ],
+ ),
+ docSection(
+ id: 'collision',
+ title: 'Collision detection',
+ children: [
+ docProse(
+ 'When a draggable overlaps several droppables, a collision '
+ 'strategy decides which one wins. The strategy is shared math in '
+ 'the engine, so Flutter and Jaspr resolve the same target:',
+ ),
+ docBullets(const [
+ 'Closest center — the target whose center is nearest the '
+ 'pointer. A solid default for grids and free layouts.',
+ 'Largest overlap — the target the dragged element overlaps most. '
+ 'Natural for list and Kanban reordering.',
+ ]),
+ ],
+ ),
+ docSection(
+ id: 'feedback',
+ title: 'Hover feedback',
+ children: [
+ docProse(
+ 'Read whether a target is currently being hovered to highlight '
+ 'it — the drop zones on this site light up exactly this way.',
+ ),
+ const CodeTabs(
+ flutterFile: 'feedback.dart',
+ jasprFile: 'feedback.dart',
+ flutter: _feedbackFlutter,
+ jaspr: _feedbackJaspr,
+ ),
+ ],
+ ),
+ nextSteps([
+ NextStep(
+ label: 'Drag overlay',
+ desc: 'Render a floating preview that follows the pointer.',
+ href: docHref('overlay'),
+ ),
+ NextStep(
+ label: 'Sortable lists',
+ desc: 'Reorder a list without wiring targets by hand.',
+ href: docHref('sortable'),
+ ),
+ ]),
+ ],
+ );
+ }
+}
+
+const _usageFlutter = '''DndDroppable(
+ id: const DndId('inbox'),
+ child: const SizedBox(width: 240, height: 160, child: Text('Inbox')),
+)''';
+
+const _usageJaspr = '''DndDroppable(
+ id: const DndId('inbox'),
+ child: div([.text('Inbox')]),
+)''';
+
+const _feedbackFlutter = '''DndDroppable(
+ id: const DndId('inbox'),
+ builder: (context, state, child) => DecoratedBox(
+ decoration: BoxDecoration(
+ border: Border.all(
+ color: state.isOver ? Colors.orange : Colors.grey,
+ ),
+ ),
+ child: child,
+ ),
+ child: const Text('Inbox'),
+)''';
+
+const _feedbackJaspr = '''DndDroppable(
+ id: const DndId('inbox'),
+ builder: (context, state, child) => div(
+ classes: state.isOver ? 'border-accent' : 'border-line',
+ [child],
+ ),
+ child: div([.text('Inbox')]),
+)''';
diff --git a/website/lib/docs/pages/install_page.dart b/website/lib/docs/pages/install_page.dart
new file mode 100644
index 0000000..6b0bb77
--- /dev/null
+++ b/website/lib/docs/pages/install_page.dart
@@ -0,0 +1,85 @@
+import 'package:jaspr/jaspr.dart';
+
+import '../doc_components.dart';
+import '../docs_nav.dart';
+import '../docs_shell.dart';
+
+/// `/docs/install`
+class InstallPage extends StatelessComponent {
+ const InstallPage({super.key});
+
+ @override
+ Component build(BuildContext context) {
+ return DocsShell(
+ slug: 'install',
+ toc: const [
+ (id: 'flutter', label: 'Flutter'),
+ (id: 'jaspr', label: 'Jaspr (web)'),
+ (id: 'engine', label: 'Shared engine'),
+ ],
+ body: [
+ docLead(
+ 'Add one package for your platform. Each adapter depends on the '
+ 'shared engine for you — you only depend on dnd_kit directly when '
+ 'building a custom adapter or testing the drag math.',
+ ),
+ youWillLearn(const [
+ 'Which package to add for Flutter, Jaspr, or a custom adapter.',
+ 'The current stable version line of the family.',
+ ]),
+ docSection(
+ id: 'flutter',
+ title: 'Flutter',
+ children: [
+ docProseRich([
+ docText('Add '),
+ inlineCode('dnd_kit_flutter'),
+ docText(' to a Flutter app:'),
+ ]),
+ docCodeBlock(
+ 'pubspec.yaml',
+ 'dependencies:\n dnd_kit_flutter: ^0.4.0',
+ ),
+ ],
+ ),
+ docSection(
+ id: 'jaspr',
+ title: 'Jaspr (web)',
+ children: [
+ docProseRich([
+ docText('Add '),
+ inlineCode('dnd_kit_jaspr'),
+ docText(' to a Jaspr app. It needs no Flutter SDK:'),
+ ]),
+ docCodeBlock(
+ 'pubspec.yaml',
+ 'dependencies:\n dnd_kit_jaspr: ^0.4.0',
+ ),
+ ],
+ ),
+ docSection(
+ id: 'engine',
+ title: 'Shared engine',
+ children: [
+ docProseRich([
+ docText('Depend on '),
+ inlineCode('dnd_kit'),
+ docText(
+ ' directly only for custom adapters or contract tests — the '
+ 'adapters already bundle it:',
+ ),
+ ]),
+ docCodeBlock('pubspec.yaml', 'dependencies:\n dnd_kit: ^0.4.0'),
+ ],
+ ),
+ nextSteps([
+ NextStep(
+ label: 'Quickstart',
+ desc: 'Wire your first drag-and-drop in three steps.',
+ href: docHref('quickstart'),
+ ),
+ ]),
+ ],
+ );
+ }
+}
diff --git a/website/lib/docs/pages/modifiers_page.dart b/website/lib/docs/pages/modifiers_page.dart
new file mode 100644
index 0000000..7fd9a71
--- /dev/null
+++ b/website/lib/docs/pages/modifiers_page.dart
@@ -0,0 +1,119 @@
+import 'package:jaspr/jaspr.dart';
+
+import '../code_tabs.dart';
+import '../doc_components.dart';
+import '../docs_nav.dart';
+import '../docs_shell.dart';
+
+/// `/docs/modifiers`
+class ModifiersPage extends StatelessComponent {
+ const ModifiersPage({super.key});
+
+ @override
+ Component build(BuildContext context) {
+ return DocsShell(
+ slug: 'modifiers',
+ toc: const [
+ (id: 'what', label: 'What modifiers do'),
+ (id: 'builtin', label: 'Built-in modifiers'),
+ (id: 'applying', label: 'Applying them'),
+ ],
+ body: [
+ docLead(
+ 'Modifiers adjust the active drag transform before it is applied — '
+ 'constrain movement to an axis, snap to a grid, or clamp within a '
+ 'boundary. Each is a pure function, so they compose cleanly.',
+ ),
+ youWillLearn(const [
+ 'What a modifier is.',
+ 'The built-in modifiers the engine ships.',
+ 'How to apply one or several at once.',
+ ]),
+ docSection(
+ id: 'what',
+ title: 'What modifiers do',
+ children: [
+ docProseRich([
+ docText('A modifier is a function from a '),
+ inlineCode('DndModifierInput'),
+ docText(' to a '),
+ inlineCode('DndTransform'),
+ docText(
+ '. The engine runs it on every frame of a drag, so the change '
+ 'is live and identical on both adapters.',
+ ),
+ ]),
+ ],
+ ),
+ docSection(
+ id: 'builtin',
+ title: 'Built-in modifiers',
+ children: [
+ docProseRich([
+ docText('They live on '),
+ inlineCode('DndModifiers'),
+ docText(':'),
+ ]),
+ docBullets(const [
+ 'restrictToHorizontalAxis — lock movement to the x axis.',
+ 'restrictToVerticalAxis — lock movement to the y axis.',
+ 'restrictToBoundary(rect) — clamp the element within a rectangle.',
+ 'snapToGrid(width: , height: ) — quantize movement to a grid.',
+ 'compose(modifiers) — apply several in order, first to last.',
+ ]),
+ ],
+ ),
+ docSection(
+ id: 'applying',
+ title: 'Applying them',
+ children: [
+ docProseRich([
+ docText('Pass modifiers to the '),
+ inlineCode('DndController'),
+ docText(' on your scope:'),
+ ]),
+ const CodeTabs(
+ flutterFile: 'scope.dart',
+ jasprFile: 'scope.dart',
+ flutter: _oneCode,
+ jaspr: _oneCode,
+ ),
+ docProse('Combine several — snap to a grid and clamp to a box:'),
+ const CodeTabs(
+ flutterFile: 'scope.dart',
+ jasprFile: 'scope.dart',
+ flutter: _manyCode,
+ jaspr: _manyCode,
+ ),
+ ],
+ ),
+ nextSteps([
+ NextStep(
+ label: 'Auto-scroll',
+ desc: 'Scroll a container while dragging past its edge.',
+ href: docHref('auto-scroll'),
+ ),
+ NextStep(
+ label: 'Collision detection',
+ desc: 'Decide which target a draggable lands on.',
+ href: docHref('collision'),
+ ),
+ ]),
+ ],
+ );
+ }
+}
+
+const _oneCode = '''DndScope(
+ controller: DndController(
+ modifiers: [DndModifiers.restrictToHorizontalAxis],
+ ),
+ child: board,
+)''';
+
+const _manyCode = '''DndController(
+ modifiers: [
+ DndModifiers.snapToGrid(width: 16, height: 16),
+ DndModifiers.restrictToBoundary(boardRect),
+ ],
+)''';
diff --git a/website/lib/docs/pages/multi_container_page.dart b/website/lib/docs/pages/multi_container_page.dart
new file mode 100644
index 0000000..6f3b8a3
--- /dev/null
+++ b/website/lib/docs/pages/multi_container_page.dart
@@ -0,0 +1,141 @@
+import 'package:jaspr/jaspr.dart';
+
+import '../code_tabs.dart';
+import '../doc_components.dart';
+import '../docs_nav.dart';
+import '../docs_shell.dart';
+
+/// `/docs/multi-container`
+class MultiContainerPage extends StatelessComponent {
+ const MultiContainerPage({super.key});
+
+ @override
+ Component build(BuildContext context) {
+ return DocsShell(
+ slug: 'multi-container',
+ toc: const [
+ (id: 'anatomy', label: 'Anatomy'),
+ (id: 'usage', label: 'Usage'),
+ (id: 'state', label: 'Applying moves'),
+ ],
+ body: [
+ docLead(
+ 'Multi-container sortable moves items within and across lists — the '
+ 'shape of a Kanban board. It is a supported preset over the shared '
+ 'engine, so the same code drives Flutter and the web.',
+ ),
+ youWillLearn(const [
+ 'The three components that make a multi-list board.',
+ 'How to wire columns and cards.',
+ 'How to apply a cross-list move to your own state.',
+ ]),
+ docSection(
+ id: 'anatomy',
+ title: 'Anatomy',
+ children: [
+ docBullets(const [
+ 'SortableMultiScope — owns the whole board and the move policy '
+ 'across all columns.',
+ 'SortableMultiContainerArea — one column; a drop region that '
+ 'holds an ordered list of items.',
+ 'SortableMultiItem — one card; draggable within and between '
+ 'columns.',
+ ]),
+ ],
+ ),
+ docSection(
+ id: 'usage',
+ title: 'Usage',
+ children: [
+ docProse(
+ 'Nest the three components and feed them your board model — a map '
+ 'of column id to the ordered card ids in that column.',
+ ),
+ const CodeTabs(
+ flutterFile: 'board.dart',
+ jasprFile: 'board.dart',
+ flutter: _usageFlutter,
+ jaspr: _usageJaspr,
+ ),
+ ],
+ ),
+ docSection(
+ id: 'state',
+ title: 'Applying moves',
+ children: [
+ docProse(
+ 'A move reports the source and target column plus the indices. '
+ 'You remove the card from its old column and insert it into the '
+ 'new one — the board owns its data, the engine only resolves '
+ 'intent. The Kanban showcase on the home page is built on exactly '
+ 'this preset.',
+ ),
+ const CodeTabs(
+ flutterFile: 'on_move.dart',
+ jasprFile: 'on_move.dart',
+ flutter: _moveCode,
+ jaspr: _moveCode,
+ ),
+ ],
+ ),
+ nextSteps([
+ NextStep(
+ label: 'Sortable lists',
+ desc: 'The single-list preset this builds on.',
+ href: docHref('sortable'),
+ ),
+ NextStep(
+ label: 'Accessibility',
+ desc: 'Keyboard moves across columns, announced.',
+ href: docHref('accessibility'),
+ ),
+ ]),
+ ],
+ );
+ }
+}
+
+const _usageFlutter = '''SortableMultiScope(
+ controller: controller,
+ columnIds: columns, // List
+ onMove: _onMove,
+ child: Row(
+ children: [
+ for (final columnId in columns)
+ SortableMultiContainerArea(
+ id: columnId,
+ itemIds: board[columnId]!,
+ child: Column(
+ children: [
+ for (final cardId in board[columnId]!)
+ SortableMultiItem(id: cardId, child: CardTile(cardId)),
+ ],
+ ),
+ ),
+ ],
+ ),
+)''';
+
+const _usageJaspr = '''SortableMultiScope(
+ controller: controller,
+ columnIds: columns, // List
+ onMove: _onMove,
+ child: div([
+ for (final columnId in columns)
+ SortableMultiContainerArea(
+ id: columnId,
+ itemIds: board[columnId]!,
+ child: div([
+ for (final cardId in board[columnId]!)
+ SortableMultiItem(id: cardId, child: cardTile(cardId)),
+ ]),
+ ),
+ ]),
+)''';
+
+const _moveCode = '''void _onMove(SortableMultiMoveDetails details) {
+ setState(() {
+ final card = board[details.fromColumn]!.removeAt(details.fromIndex);
+ board[details.toColumn]!.insert(details.toIndex, card);
+ });
+}''';
diff --git a/website/lib/docs/pages/overlay_page.dart b/website/lib/docs/pages/overlay_page.dart
new file mode 100644
index 0000000..6b0dc4f
--- /dev/null
+++ b/website/lib/docs/pages/overlay_page.dart
@@ -0,0 +1,98 @@
+import 'package:jaspr/jaspr.dart';
+
+import '../code_tabs.dart';
+import '../doc_components.dart';
+import '../docs_nav.dart';
+import '../docs_shell.dart';
+
+/// `/docs/overlay`
+class OverlayPage extends StatelessComponent {
+ const OverlayPage({super.key});
+
+ @override
+ Component build(BuildContext context) {
+ return DocsShell(
+ slug: 'overlay',
+ toc: const [
+ (id: 'why', label: 'Why an overlay'),
+ (id: 'usage', label: 'Usage'),
+ ],
+ body: [
+ docLead(
+ 'A drag overlay is a floating preview that follows the pointer while '
+ 'the original element keeps its place in the layout. It avoids '
+ 'clipping and reflow during a drag.',
+ ),
+ youWillLearn(const [
+ 'When a drag overlay helps over moving the element in place.',
+ 'How to render an overlay for the active draggable.',
+ ]),
+ docSection(
+ id: 'why',
+ title: 'Why an overlay',
+ children: [
+ docProse(
+ 'Moving the element itself works for simple cases, but a dragged '
+ 'element inside a scrollable or clipped container can be cut off, '
+ 'and reordering its siblings can cause layout jumps. An overlay '
+ 'renders the drag preview in a top layer instead, so it stays '
+ 'crisp above everything and never reflows the list underneath.',
+ ),
+ ],
+ ),
+ docSection(
+ id: 'usage',
+ title: 'Usage',
+ children: [
+ docProseRich([
+ docText('Place a '),
+ inlineCode('DndDragOverlay'),
+ docText(
+ ' inside the scope. It renders its builder only while a drag is '
+ 'active, positioned to follow the pointer.',
+ ),
+ ]),
+ const CodeTabs(
+ flutterFile: 'overlay.dart',
+ jasprFile: 'overlay.dart',
+ flutter: _usageFlutter,
+ jaspr: _usageJaspr,
+ ),
+ ],
+ ),
+ nextSteps([
+ NextStep(
+ label: 'Sortable lists',
+ desc: 'Reorder a list with the sortable preset.',
+ href: docHref('sortable'),
+ ),
+ NextStep(
+ label: 'Accessibility',
+ desc: 'Keyboard dragging and screen-reader announcements.',
+ href: docHref('accessibility'),
+ ),
+ ]),
+ ],
+ );
+ }
+}
+
+const _usageFlutter = '''DndScope(
+ child: Stack(
+ children: [
+ board,
+ DndDragOverlay(
+ builder: (context, activeId) => CardTile(cardFor(activeId)),
+ ),
+ ],
+ ),
+)''';
+
+const _usageJaspr = '''DndScope(
+ child: div([
+ board,
+ DndDragOverlay(
+ builder: (context, activeId) => cardTile(cardFor(activeId)),
+ ),
+ ]),
+)''';
diff --git a/website/lib/docs/pages/overview_page.dart b/website/lib/docs/pages/overview_page.dart
new file mode 100644
index 0000000..b343dbe
--- /dev/null
+++ b/website/lib/docs/pages/overview_page.dart
@@ -0,0 +1,109 @@
+import 'package:jaspr/jaspr.dart';
+
+import '../../data/site_data.dart';
+import '../doc_components.dart';
+import '../docs_nav.dart';
+import '../docs_shell.dart';
+
+/// `/docs` — the documentation landing page.
+class OverviewPage extends StatelessComponent {
+ const OverviewPage({super.key});
+
+ @override
+ Component build(BuildContext context) {
+ return DocsShell(
+ slug: '',
+ toc: const [
+ (id: 'features', label: 'Key features'),
+ (id: 'packages', label: 'Choose a package'),
+ (id: 'concepts', label: 'Core concepts'),
+ ],
+ body: [
+ docLead(
+ 'dnd_kit is one drag-and-drop engine for Flutter and the web. A '
+ 'framework-neutral core does the drag math; thin adapters render it '
+ 'on each platform — so the same API moves cards on Flutter and in '
+ 'the browser.',
+ ),
+ docSection(
+ id: 'features',
+ title: 'Key features',
+ children: [
+ docBullets(const [
+ 'One engine, two adapters — identical collision, modifier, and '
+ 'sortable math on Flutter and the web.',
+ 'Batteries included — draggable, droppable, drag overlay, '
+ 'sortable lists, and multi-container Kanban presets.',
+ 'Accessible by default — keyboard dragging and live-region '
+ 'announcements are built in, not bolted on.',
+ 'SSR-safe on the web — components pre-render on the server and '
+ 'hydrate without DOM access at import time.',
+ ]),
+ ],
+ ),
+ docSection(
+ id: 'packages',
+ title: 'Choose a package',
+ children: [
+ docProse('Pick the package for your platform, then read on:'),
+ nextSteps([
+ NextStep(
+ label: 'dnd_kit_flutter',
+ desc: 'Flutter apps — widgets, sensors, overlays, sortable.',
+ href: SiteLinks.pubFlutter,
+ external: true,
+ ),
+ NextStep(
+ label: 'dnd_kit_jaspr',
+ desc:
+ 'Jaspr (Dart web) apps — the web adapter, no Flutter SDK.',
+ href: SiteLinks.pubJaspr,
+ external: true,
+ ),
+ NextStep(
+ label: 'dnd_kit',
+ desc: 'The shared engine — custom adapters and drag math.',
+ href: SiteLinks.pubKit,
+ external: true,
+ ),
+ NextStep(
+ label: 'Installation',
+ desc: 'Add a package and start dragging.',
+ href: docHref('install'),
+ ),
+ ]),
+ ],
+ ),
+ docSection(
+ id: 'concepts',
+ title: 'Core concepts',
+ children: [
+ docProse('The essential building blocks, smallest first:'),
+ nextSteps([
+ NextStep(
+ label: 'Quickstart',
+ desc: 'Wire a draggable and a droppable in three steps.',
+ href: docHref('quickstart'),
+ ),
+ NextStep(
+ label: 'Draggable',
+ desc: 'Make any widget or element pick-up-able.',
+ href: docHref('draggable'),
+ ),
+ NextStep(
+ label: 'Droppable',
+ desc: 'Define the targets a draggable can land on.',
+ href: docHref('droppable'),
+ ),
+ NextStep(
+ label: 'Sortable lists',
+ desc: 'Reorder a list with the sortable preset.',
+ href: docHref('sortable'),
+ ),
+ ]),
+ ],
+ ),
+ ],
+ );
+ }
+}
diff --git a/website/lib/docs/pages/quickstart_page.dart b/website/lib/docs/pages/quickstart_page.dart
new file mode 100644
index 0000000..1bbc52b
--- /dev/null
+++ b/website/lib/docs/pages/quickstart_page.dart
@@ -0,0 +1,187 @@
+import 'package:jaspr/jaspr.dart';
+
+import '../code_tabs.dart';
+import '../doc_components.dart';
+import '../docs_nav.dart';
+import '../docs_shell.dart';
+
+/// `/docs/quickstart`
+class QuickstartPage extends StatelessComponent {
+ const QuickstartPage({super.key});
+
+ @override
+ Component build(BuildContext context) {
+ return DocsShell(
+ slug: 'quickstart',
+ toc: const [
+ (id: 'scope', label: 'Wrap in a scope'),
+ (id: 'draggable', label: 'Make a draggable'),
+ (id: 'droppable', label: 'Add a droppable'),
+ (id: 'together', label: 'Put it together'),
+ ],
+ body: [
+ docLead(
+ 'Three steps to your first drag-and-drop: wrap an area in a scope, '
+ 'mark a draggable and a drop target, then react when they meet. The '
+ 'API is the same on Flutter and Jaspr — switch the tab on any '
+ 'snippet to compare.',
+ ),
+ youWillLearn(const [
+ 'How to wrap an interactive area in a DndScope.',
+ 'How to make an element draggable and define a drop target.',
+ 'How to react to a drop with onDragEnd.',
+ ]),
+ docSection(
+ id: 'scope',
+ title: 'Wrap in a scope',
+ children: [
+ docProseRich([
+ docText('A '),
+ inlineCode('DndScope'),
+ docText(
+ ' owns one drag interaction. Everything draggable or droppable '
+ 'lives inside it. Start by wrapping the area you want to make '
+ 'interactive.',
+ ),
+ ]),
+ ],
+ ),
+ docSection(
+ id: 'draggable',
+ title: 'Make a draggable',
+ children: [
+ docProseRich([
+ docText('Wrap any element in a '),
+ inlineCode('DndDraggable'),
+ docText(' with a unique '),
+ inlineCode('DndId'),
+ docText('. That is all it takes to pick it up.'),
+ ]),
+ const CodeTabs(
+ flutterFile: 'draggable.dart',
+ jasprFile: 'draggable.dart',
+ flutter: _draggableFlutter,
+ jaspr: _draggableJaspr,
+ ),
+ ],
+ ),
+ docSection(
+ id: 'droppable',
+ title: 'Add a droppable',
+ children: [
+ docProseRich([
+ docText('A '),
+ inlineCode('DndDroppable'),
+ docText(
+ ' is a target a draggable can land on. It also takes a unique '
+ 'id, which you read back when the drag ends.',
+ ),
+ ]),
+ const CodeTabs(
+ flutterFile: 'droppable.dart',
+ jasprFile: 'droppable.dart',
+ flutter: _droppableFlutter,
+ jaspr: _droppableJaspr,
+ ),
+ ],
+ ),
+ docSection(
+ id: 'together',
+ title: 'Put it together',
+ children: [
+ docProseRich([
+ docText('Listen to '),
+ inlineCode('onDragEnd'),
+ docText(
+ ' to move your data when a draggable lands on a target. You own '
+ 'the state; dnd_kit reports the move.',
+ ),
+ ]),
+ const CodeTabs(
+ flutterFile: 'main.dart',
+ jasprFile: 'main.dart',
+ flutter: _togetherFlutter,
+ jaspr: _togetherJaspr,
+ ),
+ ],
+ ),
+ nextSteps([
+ NextStep(
+ label: 'Draggable',
+ desc: 'Drag handles, activation, and drag data in depth.',
+ href: docHref('draggable'),
+ ),
+ NextStep(
+ label: 'Sortable lists',
+ desc: 'Reorder a list with the sortable preset.',
+ href: docHref('sortable'),
+ ),
+ ]),
+ ],
+ );
+ }
+}
+
+const _draggableFlutter =
+ '''import 'package:dnd_kit_flutter/dnd_kit_flutter.dart';
+import 'package:flutter/widgets.dart';
+
+DndDraggable(
+ id: const DndId('card'),
+ child: const Text('Drag me'),
+)''';
+
+const _draggableJaspr = '''import 'package:dnd_kit_jaspr/dnd_kit_jaspr.dart';
+
+DndDraggable(
+ id: const DndId('card'),
+ child: div([.text('Drag me')]),
+)''';
+
+const _droppableFlutter = '''DndDroppable(
+ id: const DndId('inbox'),
+ child: const Text('Inbox'),
+)''';
+
+const _droppableJaspr = '''DndDroppable(
+ id: const DndId('inbox'),
+ child: div([.text('Inbox')]),
+)''';
+
+const _togetherFlutter = '''DndScope(
+ child: Column(
+ children: [
+ DndDraggable(
+ id: const DndId('card'),
+ onDragEnd: (event) {
+ if (event.overId == const DndId('inbox')) {
+ moveCardToInbox();
+ }
+ },
+ child: const Text('Drag me'),
+ ),
+ DndDroppable(
+ id: const DndId('inbox'),
+ child: const Text('Inbox'),
+ ),
+ ],
+ ),
+)''';
+
+const _togetherJaspr = '''DndScope(
+ child: div([
+ DndDraggable(
+ id: const DndId('card'),
+ onDragEnd: (event) {
+ if (event.overId == const DndId('inbox')) {
+ moveCardToInbox();
+ }
+ },
+ child: div([.text('Drag me')]),
+ ),
+ DndDroppable(
+ id: const DndId('inbox'),
+ child: div([.text('Inbox')]),
+ ),
+ ]),
+)''';
diff --git a/website/lib/docs/pages/reference_page.dart b/website/lib/docs/pages/reference_page.dart
new file mode 100644
index 0000000..c091ba8
--- /dev/null
+++ b/website/lib/docs/pages/reference_page.dart
@@ -0,0 +1,106 @@
+import 'package:jaspr/jaspr.dart';
+
+import '../../data/site_data.dart';
+import '../doc_components.dart';
+import '../docs_shell.dart';
+
+/// `/docs/reference`
+class ReferencePage extends StatelessComponent {
+ const ReferencePage({super.key});
+
+ @override
+ Component build(BuildContext context) {
+ return DocsShell(
+ slug: 'reference',
+ toc: const [
+ (id: 'api', label: 'API docs'),
+ (id: 'changelog', label: 'Changelog'),
+ (id: 'source', label: 'Source & issues'),
+ ],
+ body: [
+ docLead(
+ 'The full generated API reference lives on pub.dev. Browse the '
+ 'package you depend on, or read the engine for the shared contracts.',
+ ),
+ docSection(
+ id: 'api',
+ title: 'API docs',
+ children: [
+ nextSteps([
+ NextStep(
+ label: 'dnd_kit_flutter',
+ desc: 'Flutter adapter — widgets, sensors, overlays, sortable.',
+ href: _doc('dnd_kit_flutter'),
+ external: true,
+ ),
+ NextStep(
+ label: 'dnd_kit_jaspr',
+ desc:
+ 'Jaspr (web) adapter — components over the shared engine.',
+ href: _doc('dnd_kit_jaspr'),
+ external: true,
+ ),
+ NextStep(
+ label: 'dnd_kit',
+ desc: 'Shared engine — collision, modifiers, sortable math.',
+ href: _doc('dnd_kit'),
+ external: true,
+ ),
+ ]),
+ ],
+ ),
+ docSection(
+ id: 'changelog',
+ title: 'Changelog',
+ children: [
+ docProse(
+ 'Each package publishes its changelog on pub.dev. The family '
+ 'releases in lockstep — engine first, then the adapters.',
+ ),
+ nextSteps([
+ NextStep(
+ label: 'dnd_kit_flutter changelog',
+ desc: 'Release notes for the Flutter adapter.',
+ href: _changelog('dnd_kit_flutter'),
+ external: true,
+ ),
+ NextStep(
+ label: 'dnd_kit_jaspr changelog',
+ desc: 'Release notes for the web adapter.',
+ href: _changelog('dnd_kit_jaspr'),
+ external: true,
+ ),
+ NextStep(
+ label: 'dnd_kit changelog',
+ desc: 'Release notes for the shared engine.',
+ href: _changelog('dnd_kit'),
+ external: true,
+ ),
+ ]),
+ ],
+ ),
+ docSection(
+ id: 'source',
+ title: 'Source & issues',
+ children: [
+ docProse(
+ 'The whole family lives in one repository. File issues and read '
+ 'the examples there.',
+ ),
+ nextSteps([
+ NextStep(
+ label: 'GitHub repository',
+ desc: 'Source, examples, and issue tracker.',
+ href: SiteLinks.github,
+ external: true,
+ ),
+ ]),
+ ],
+ ),
+ ],
+ );
+ }
+}
+
+String _doc(String pkg) => 'https://pub.dev/documentation/$pkg/latest/';
+String _changelog(String pkg) => 'https://pub.dev/packages/$pkg/changelog';
diff --git a/website/lib/docs/pages/sensors_page.dart b/website/lib/docs/pages/sensors_page.dart
new file mode 100644
index 0000000..cf89e09
--- /dev/null
+++ b/website/lib/docs/pages/sensors_page.dart
@@ -0,0 +1,121 @@
+import 'package:jaspr/jaspr.dart';
+
+import '../code_tabs.dart';
+import '../doc_components.dart';
+import '../docs_nav.dart';
+import '../docs_shell.dart';
+
+/// `/docs/sensors`
+class SensorsPage extends StatelessComponent {
+ const SensorsPage({super.key});
+
+ @override
+ Component build(BuildContext context) {
+ return DocsShell(
+ slug: 'sensors',
+ toc: const [
+ (id: 'what', label: 'What sensors do'),
+ (id: 'activation', label: 'Activation constraints'),
+ (id: 'keyboard', label: 'Keyboard'),
+ ],
+ body: [
+ docLead(
+ 'Sensors translate raw input — pointer, keyboard — into drag intent. '
+ 'The pointer sensor is active by default; activation constraints '
+ 'decide how deliberate a gesture must be before a drag begins.',
+ ),
+ youWillLearn(const [
+ 'How sensors turn input into drags.',
+ 'How to require a distance or delay before activation.',
+ 'How keyboard dragging fits in.',
+ ]),
+ docSection(
+ id: 'what',
+ title: 'What sensors do',
+ children: [
+ docProseRich([
+ docText('A '),
+ inlineCode('DndSensor'),
+ docText(
+ ' watches an input source and starts, updates, and ends '
+ 'a drag on the controller. The built-in ',
+ ),
+ inlineCode('DndPointerSensor'),
+ docText(
+ ' handles mouse, touch, and pen through unified pointer '
+ 'events, so the same code works across devices.',
+ ),
+ ]),
+ ],
+ ),
+ docSection(
+ id: 'activation',
+ title: 'Activation constraints',
+ children: [
+ docProseRich([
+ docText('A '),
+ inlineCode('DndSensorActivationConstraint'),
+ docText(
+ ' delays activation until the gesture clears a threshold, so a '
+ 'tap or click is never mistaken for a drag. Set a small '
+ 'distance for immediate-feeling drags, or a delay for '
+ 'press-and-hold.',
+ ),
+ ]),
+ const CodeTabs(
+ flutterFile: 'activation.dart',
+ jasprFile: 'activation.dart',
+ flutter: _distanceCode,
+ jaspr: _distanceCode,
+ ),
+ docProse('A press delay instead of a distance:'),
+ const CodeTabs(
+ flutterFile: 'activation.dart',
+ jasprFile: 'activation.dart',
+ flutter: _delayCode,
+ jaspr: _delayCode,
+ ),
+ ],
+ ),
+ docSection(
+ id: 'keyboard',
+ title: 'Keyboard',
+ children: [
+ docProse(
+ 'Keyboard dragging is built in: a focused draggable is picked up '
+ 'with space or enter, moved with the arrow keys, and cancelled '
+ 'with escape — no extra wiring. See the accessibility page for '
+ 'the full keyboard model.',
+ ),
+ ],
+ ),
+ nextSteps([
+ NextStep(
+ label: 'Accessibility',
+ desc: 'The full keyboard and announcement model.',
+ href: docHref('accessibility'),
+ ),
+ NextStep(
+ label: 'Modifiers',
+ desc: 'Constrain the drag transform.',
+ href: docHref('modifiers'),
+ ),
+ ]),
+ ],
+ );
+ }
+}
+
+const _distanceCode = '''DndDraggable(
+ id: const DndId('card'),
+ constraint: const DndSensorActivationConstraint(distance: 6),
+ child: card,
+)''';
+
+const _delayCode = '''DndDraggable(
+ id: const DndId('card'),
+ constraint: const DndSensorActivationConstraint(
+ delay: Duration(milliseconds: 200),
+ ),
+ child: card,
+)''';
diff --git a/website/lib/docs/pages/sortable_page.dart b/website/lib/docs/pages/sortable_page.dart
new file mode 100644
index 0000000..c69862b
--- /dev/null
+++ b/website/lib/docs/pages/sortable_page.dart
@@ -0,0 +1,138 @@
+import 'package:jaspr/jaspr.dart';
+
+import '../code_tabs.dart';
+import '../doc_components.dart';
+import '../docs_nav.dart';
+import '../docs_shell.dart';
+
+/// `/docs/sortable`
+class SortablePage extends StatelessComponent {
+ const SortablePage({super.key});
+
+ @override
+ Component build(BuildContext context) {
+ return DocsShell(
+ slug: 'sortable',
+ toc: const [
+ (id: 'usage', label: 'Usage'),
+ (id: 'strategies', label: 'Strategies'),
+ (id: 'state', label: 'Managing state'),
+ ],
+ body: [
+ docLead(
+ 'The sortable preset turns a list into a reorderable one without '
+ 'wiring draggables and droppables by hand. Wrap the list in a '
+ 'SortableScope and each item in a SortableItem.',
+ ),
+ youWillLearn(const [
+ 'How to make a single list reorderable.',
+ 'Which layout strategy to pick for lists and grids.',
+ 'How to apply the reported move to your own state.',
+ ]),
+ docSection(
+ id: 'usage',
+ title: 'Usage',
+ children: [
+ docProseRich([
+ docText('A '),
+ inlineCode('SortableScope'),
+ docText(' takes the ordered '),
+ inlineCode('itemIds'),
+ docText(' and an '),
+ inlineCode('onMove'),
+ docText(' callback; each child is a '),
+ inlineCode('SortableItem'),
+ docText(' with a matching id.'),
+ ]),
+ const CodeTabs(
+ flutterFile: 'sortable.dart',
+ jasprFile: 'sortable.dart',
+ flutter: _usageFlutter,
+ jaspr: _usageJaspr,
+ ),
+ ],
+ ),
+ docSection(
+ id: 'strategies',
+ title: 'Strategies',
+ children: [
+ docProseRich([
+ docText('Pick a '),
+ inlineCode('SortableStrategies'),
+ docText(' to match your layout:'),
+ ]),
+ docBullets(const [
+ 'verticalList — a stacked column of items.',
+ 'horizontalList — a row of items, e.g. tabs or nav pills.',
+ 'grid — a wrapping grid that reflows in two dimensions.',
+ ]),
+ ],
+ ),
+ docSection(
+ id: 'state',
+ title: 'Managing state',
+ children: [
+ docProse(
+ 'dnd_kit reports a move as from/to indices — you own the list and '
+ 'apply it. Reordering is a remove-then-insert on your data:',
+ ),
+ const CodeTabs(
+ flutterFile: 'on_move.dart',
+ jasprFile: 'on_move.dart',
+ flutter: _moveFlutter,
+ jaspr: _moveJaspr,
+ ),
+ ],
+ ),
+ nextSteps([
+ NextStep(
+ label: 'Multi-container sortable',
+ desc: 'Move items across lists — a Kanban board.',
+ href: docHref('multi-container'),
+ ),
+ NextStep(
+ label: 'Accessibility',
+ desc: 'Keyboard reordering and announcements.',
+ href: docHref('accessibility'),
+ ),
+ ]),
+ ],
+ );
+ }
+}
+
+const _usageFlutter = '''SortableScope(
+ controller: controller,
+ strategy: SortableStrategies.verticalList,
+ itemIds: order, // List
+ onMove: _onMove,
+ child: Column(
+ children: [
+ for (final id in order) SortableItem(id: id, child: CardTile(id)),
+ ],
+ ),
+)''';
+
+const _usageJaspr = '''SortableScope(
+ controller: controller,
+ strategy: SortableStrategies.verticalList,
+ itemIds: order, // List
+ onMove: _onMove,
+ child: div([
+ for (final id in order) SortableItem(id: id, child: cardTile(id)),
+ ]),
+)''';
+
+const _moveFlutter = '''void _onMove(SortableMoveDetails details) {
+ setState(() {
+ final id = order.removeAt(details.fromIndex);
+ order.insert(details.toIndex, id);
+ });
+}''';
+
+const _moveJaspr = '''void _onMove(SortableMoveDetails details) {
+ setState(() {
+ final id = order.removeAt(details.fromIndex);
+ order.insert(details.toIndex, id);
+ });
+}''';
diff --git a/website/lib/layout/mobile_nav.dart b/website/lib/layout/mobile_nav.dart
index 662aa60..af0cc60 100644
--- a/website/lib/layout/mobile_nav.dart
+++ b/website/lib/layout/mobile_nav.dart
@@ -28,8 +28,11 @@ class _MobileNavState extends State {
if (kIsWeb) {
if (href.startsWith('#')) {
web.window.location.hash = href;
- } else {
+ } else if (href.startsWith('http')) {
web.window.open(href, '_blank');
+ } else {
+ // Internal route (e.g. the docs page) — navigate in the same tab.
+ web.window.location.href = href;
}
}
_close();
diff --git a/website/lib/layout/nav_bar.dart b/website/lib/layout/nav_bar.dart
index acc3199..3770b14 100644
--- a/website/lib/layout/nav_bar.dart
+++ b/website/lib/layout/nav_bar.dart
@@ -10,7 +10,10 @@ import 'mobile_nav.dart';
/// Sticky top navigation. The in-page links are reorderable (drag a pill to
/// rearrange them) while still navigating on a plain click.
class NavBar extends StatelessComponent {
- const NavBar({super.key});
+ const NavBar({this.activeDocs = false, super.key});
+
+ /// Highlights the Docs pill when the docs page is the current route.
+ final bool activeDocs;
@override
Component build(BuildContext context) {
@@ -43,7 +46,10 @@ class NavBar extends StatelessComponent {
),
a(
href: SiteLinks.docs,
- classes: 'pill-link hidden sm:inline-block',
+ classes:
+ 'pill-link hidden sm:inline-block'
+ '${activeDocs ? ' text-accent' : ''}',
+ attributes: activeDocs ? const {'aria-current': 'page'} : null,
[.text('Docs')],
),
const ThemeToggle(),
diff --git a/website/lib/main.client.options.dart b/website/lib/main.client.options.dart
index 0335885..2e6ed18 100644
--- a/website/lib/main.client.options.dart
+++ b/website/lib/main.client.options.dart
@@ -6,6 +6,7 @@
import 'package:jaspr/client.dart';
+import 'package:dnd_kit_website/docs/code_tabs.dart' deferred as _code_tabs;
import 'package:dnd_kit_website/drag/telemetry_hud.dart'
deferred as _telemetry_hud;
import 'package:dnd_kit_website/layout/mobile_nav.dart' deferred as _mobile_nav;
@@ -39,6 +40,15 @@ import 'package:dnd_kit_website/theme/theme_toggle.dart'
/// ```
ClientOptions get defaultClientOptions => ClientOptions(
clients: {
+ 'code_tabs': ClientLoader(
+ (p) => _code_tabs.CodeTabs(
+ flutter: p['flutter'] as String,
+ jaspr: p['jaspr'] as String,
+ flutterFile: p['flutterFile'] as String,
+ jasprFile: p['jasprFile'] as String,
+ ),
+ loader: _code_tabs.loadLibrary,
+ ),
'telemetry_hud': ClientLoader(
(p) => _telemetry_hud.TelemetryHud(),
loader: _telemetry_hud.loadLibrary,
diff --git a/website/lib/main.server.dart b/website/lib/main.server.dart
index 043d722..a704993 100644
--- a/website/lib/main.server.dart
+++ b/website/lib/main.server.dart
@@ -1,8 +1,8 @@
import 'package:jaspr/dom.dart';
import 'package:jaspr/server.dart';
+import 'app.dart';
import 'main.server.options.dart';
-import 'site.dart';
/// Google Fonts: Newsreader (display serif), Hanken Grotesk (body),
/// Geist Mono (utility/code).
@@ -88,7 +88,7 @@ void main() {
children: const [RawText(_noFlashScript)],
),
],
- body: const Site(),
+ body: const App(),
),
);
}
diff --git a/website/lib/main.server.options.dart b/website/lib/main.server.options.dart
index 39ae320..f1361fa 100644
--- a/website/lib/main.server.options.dart
+++ b/website/lib/main.server.options.dart
@@ -5,6 +5,7 @@
// Generated with jaspr_builder
import 'package:jaspr/server.dart';
+import 'package:dnd_kit_website/docs/code_tabs.dart' as _code_tabs;
import 'package:dnd_kit_website/drag/telemetry_hud.dart' as _telemetry_hud;
import 'package:dnd_kit_website/layout/mobile_nav.dart' as _mobile_nav;
import 'package:dnd_kit_website/layout/nav_bar.dart' as _nav_bar;
@@ -35,6 +36,10 @@ import 'package:dnd_kit_website/theme/theme_toggle.dart' as _theme_toggle;
ServerOptions get defaultServerOptions => ServerOptions(
clientId: 'main.client.dart.js',
clients: {
+ _code_tabs.CodeTabs: ClientTarget<_code_tabs.CodeTabs>(
+ 'code_tabs',
+ params: __code_tabsCodeTabs,
+ ),
_telemetry_hud.TelemetryHud: ClientTarget<_telemetry_hud.TelemetryHud>(
'telemetry_hud',
),
@@ -53,3 +58,10 @@ ServerOptions get defaultServerOptions => ServerOptions(
),
},
);
+
+Map __code_tabsCodeTabs(_code_tabs.CodeTabs c) => {
+ 'flutter': c.flutter,
+ 'jaspr': c.jaspr,
+ 'flutterFile': c.flutterFile,
+ 'jasprFile': c.jasprFile,
+};
diff --git a/website/pubspec.yaml b/website/pubspec.yaml
index 548e55b..6812eff 100644
--- a/website/pubspec.yaml
+++ b/website/pubspec.yaml
@@ -12,6 +12,7 @@ dependencies:
dnd_kit_jaspr:
path: ../packages/dnd_kit_jaspr
jaspr: ^0.23.1
+ jaspr_router: ^0.8.2
universal_web: ^1.1.1
dev_dependencies: