diff --git a/docs/app/content/articles/core-build.md b/docs/app/content/articles/core-build.md
index de0257f..7af1977 100644
--- a/docs/app/content/articles/core-build.md
+++ b/docs/app/content/articles/core-build.md
@@ -1,11 +1,11 @@
---
title: Core build
-description: Use the core engine (~10 kB) and compose only the actions you need
+description: Use the core engine kB) and compose only the actions you need
category: advanced
position: 3
---
-The full Attractive.js build includes all built-in actions at ~20 kB (6.6 kB gzipped). The core build strips out all actions, only the engine at ~10 kB (~3.5 kB gzipped).
+The full Attractive.js build includes all built-in actions, you can also just use the core and compose actions as you need.
```js
import Attractive from "attractivejs/core";
```
diff --git a/docs/app/content/articles/element.md b/docs/app/content/articles/element.md
new file mode 100644
index 0000000..1316c62
--- /dev/null
+++ b/docs/app/content/articles/element.md
@@ -0,0 +1,127 @@
+---
+title: Attractive Element
+description: A base class for custom elements that scopes Attractive to the component. Actions, targets and lifecycle without the boilerplate.
+category: extensions
+position: 5
+---
+
+`AttractiveElement` is a base class for custom elements. It scopes Attractive to the component, so `@click`, `@target`, gates and triggers work inside the component without a `connectedCallback`, `querySelector` or manual teardown.
+
+```js
+import { AttractiveElement } from "attractivejs/element";
+```
+
+
+## A simple example: tabs
+
+Actions in HTML call component methods directly. The component manages its own state and resolves targets within its own subtree.
+
+```html
+
+
+
+
…
+
…
+
+```
+
+```js
+class Tabs extends AttractiveElement {
+ connect() {
+ this.#show("details");
+ }
+
+ select(element, { dataset }) {
+ this.#show(dataset.panel);
+ }
+
+ // private
+
+ #show(name) {
+ this.targets(".panel").forEach((panel) => (panel.hidden = true));
+
+ this.target(name).hidden = false;
+ }
+}
+
+customElements.define("ui-tabs", Tabs);
+```
+
+The action name in the attribute resolves to a method on the class, called with the element and its context (so `select` receives the clicked button's `dataset`). Private methods stay out of HTML.
+
+What the class handles for you:
+
+| Boilerplate | Replaced by |
+| ----------------------------- | ------------------------ |
+| `connectedCallback` | `connect()` |
+| `disconnectedCallback` | `disconnect()` |
+| `new Attractive()` + `activate({ on: this })` | automatic |
+| `deactivate()` | automatic |
+| `this.querySelector("#menu")` | `this.target("menu")` |
+| `document.querySelector(…)` | `this.element(…)` |
+
+
+## Lifecycle
+
+`connect()` runs once the component's scope is active; `disconnect()` runs before it is torn down.
+
+```js
+class Counter extends AttractiveElement {
+ connect() {
+ this.count = 0;
+ }
+
+ increment() {
+ this.target("count").textContent = ++this.count;
+ }
+}
+```
+
+
+## Scoped targets
+
+`this.target(id)` and `this.targets(selector)` query within the component. `target()` takes a bare id, the same convention as `@target`.
+
+```html
+
+
+
+
+
+```
+
+Each component resolves its own `#count`, even when the same id appears on the page twice.
+
+
+## Any element on the page
+
+`this.element(selector)` and `this.elements(selector)` reach the rest of the document. This is useful for calling methods on other components.
+
+```js
+class Form extends AttractiveElement {
+ submit() {
+ this.element("ui-button").activate();
+ }
+}
+```
+
+
+## Reactive targets
+
+A method named `{id}TargetConnected()` runs when an element with that id enters the component, and `{id}TargetDisconnected()` when it leaves. This catches targets added after the component connects.
+
+```js
+class Panel extends AttractiveElement {
+ detailsTargetConnected(element) {
+ element.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 200 });
+ }
+}
+```
+
+
+## Alongside a document-wide activation
+
+Components work when a global `Attractive.activate()` is also running. The component manages its own subtree and the page-wide activation leaves it alone, so actions never fire twice.
diff --git a/package.json b/package.json
index a095ad0..0182644 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,9 @@
"./core": {
"import": "./dist/attractive.core.js"
},
+ "./element": {
+ "import": "./dist/element.js"
+ },
"./actions": {
"import": "./dist/actions/index.js"
},
diff --git a/playground/index.html b/playground/index.html
index 95bf6b4..ad0fc60 100644
--- a/playground/index.html
+++ b/playground/index.html
@@ -8,6 +8,36 @@
/>
@@ -21,6 +51,34 @@
Copy
+
Tabs
+
+ A <ui-tabs> component: HTML actions call the class
+ methods, target()/targets() stay scoped.
+
+
+
+
+
+
Details content
+
Settings content
+
+
+
Counter
+
+ A <ui-counter> component: state in
+ connect(), HTML actions call methods, and
+ target("count") stays scoped.
+