> ## Documentation Index
> Fetch the complete documentation index at: https://prefab.prefect.io/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom Handlers

> Extend Prefab with developer-authored JavaScript for custom pipes and client-side logic.

export const ComponentPreview = ({json, height, resizable, bare, hideJson, playground, children}) => {
  const hostRef = React.useRef(null);
  const handleRef = React.useRef(null);
  const cardRef = React.useRef(null);
  const containerRef = React.useRef(null);
  const [previewWidth, setPreviewWidth] = React.useState(null);
  const [isDragging, setIsDragging] = React.useState(false);
  const [playgroundHref, setPlaygroundHref] = React.useState(null);
  React.useEffect(function () {
    if (!playground) return;
    var isLocal = location.hostname === "localhost" || location.hostname === "127.0.0.1";
    var path = isLocal ? "/playground" : "/docs/playground";
    var url = new URL(path, window.location.href);
    url.hash = "code=" + playground;
    setPlaygroundHref(url.href);
  }, [playground]);
  var jsonStr = typeof json === "string" ? json : JSON.stringify(json, null, 2);
  React.useEffect(function () {
    if (cardRef.current) {
      var card = cardRef.current.closest(".group");
      if (card) card.classList.remove("group");
    }
    var host = hostRef.current;
    if (!host || !json) return;
    function mount() {
      if (!window.__prefab || !host) return;
      var dark = document.documentElement.classList.contains("dark");
      handleRef.current = window.__prefab.mountPreview(host, jsonStr, {
        dark: dark
      });
      if (bare && host.shadowRoot) {
        var m = host.shadowRoot.querySelector("[data-prefab-mount]");
        if (m) m.style.background = "transparent";
      }
    }
    if (window.__prefab) {
      mount();
    } else {
      if (!window.__prefabLoading) {
        if (!window.__prefabReady) {
          var s = document.createElement("script");
          s.src = "/renderer.js";
          document.head.appendChild(s);
        }
        window.__prefabLoading = new Promise(function (resolve) {
          function check() {
            if (window.__prefabReady) {
              window.__prefabReady.then(resolve);
            } else {
              setTimeout(check, 10);
            }
          }
          check();
        });
      }
      window.__prefabLoading.then(mount);
    }
    var observer = new MutationObserver(function () {
      var dark = document.documentElement.classList.contains("dark");
      if (handleRef.current) handleRef.current.setDark(dark);
    });
    observer.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"]
    });
    return function () {
      observer.disconnect();
      if (handleRef.current) {
        handleRef.current.unmount();
        handleRef.current = null;
      }
    };
  }, [json]);
  function startResize(e) {
    e.preventDefault();
    var startX = e.clientX;
    var startW = hostRef.current ? hostRef.current.offsetWidth : 0;
    var maxW = containerRef.current ? containerRef.current.offsetWidth - 10 : startW;
    setIsDragging(true);
    function onMove(ev) {
      var w = Math.max(260, Math.min(startW + (ev.clientX - startX), maxW));
      setPreviewWidth(w);
    }
    function onUp() {
      document.removeEventListener("mousemove", onMove);
      document.removeEventListener("mouseup", onUp);
      setIsDragging(false);
    }
    document.addEventListener("mousemove", onMove);
    document.addEventListener("mouseup", onUp);
  }
  if (bare) {
    return <div ref={hostRef} />;
  }
  if (resizable) {
    return <Card>
        <div ref={cardRef} />
        <div ref={containerRef} style={{
      display: "flex",
      position: "relative",
      overflow: "hidden"
    }}>
          <div ref={hostRef} style={{
      flex: previewWidth ? "none" : 1,
      width: previewWidth ? previewWidth + "px" : undefined,
      minWidth: 0
    }} />
          <div onMouseDown={startResize} style={{
      width: "14px",
      flexShrink: 0,
      cursor: "col-resize",
      background: isDragging ? "var(--border, #e5e7eb)" : "color-mix(in srgb, var(--border, #e5e7eb) 40%, transparent)",
      display: "flex",
      alignItems: "center",
      justifyContent: "center",
      transition: "background 0.15s",
      userSelect: "none"
    }}>
            <svg width="6" height="24" style={{
      opacity: 0.5
    }}>
              <circle cx="2" cy="4" r="1.2" fill="currentColor" />
              <circle cx="2" cy="9" r="1.2" fill="currentColor" />
              <circle cx="2" cy="14" r="1.2" fill="currentColor" />
              <circle cx="2" cy="19" r="1.2" fill="currentColor" />
              <circle cx="5" cy="4" r="1.2" fill="currentColor" />
              <circle cx="5" cy="9" r="1.2" fill="currentColor" />
              <circle cx="5" cy="14" r="1.2" fill="currentColor" />
              <circle cx="5" cy="19" r="1.2" fill="currentColor" />
            </svg>
          </div>
          <div style={{
      flex: previewWidth ? 1 : "0 0 24px",
      minWidth: "24px",
      background: "repeating-linear-gradient(-45deg, transparent, transparent 3px, var(--border, #e5e7eb) 3px, var(--border, #e5e7eb) 4px)",
      opacity: 0.4
    }} />
          {previewWidth && <div style={{
      position: "absolute",
      bottom: "8px",
      right: "8px",
      background: "var(--background, white)",
      border: "1px solid var(--border, #e5e7eb)",
      borderRadius: "4px",
      padding: "1px 6px",
      fontSize: "11px",
      fontFamily: "monospace",
      color: "var(--muted-foreground, #6b7280)",
      pointerEvents: "none"
    }}>
              {previewWidth}px
            </div>}
        </div>
        {playgroundHref && <div style={{
      display: "flex",
      justifyContent: "flex-end",
      padding: "4px 0 8px"
    }}>
            <a href={playgroundHref} target="_blank" rel="noopener noreferrer" style={{
      fontSize: "11px",
      opacity: 0.4,
      textDecoration: "none",
      textDecorationLine: "none",
      borderBottom: "none",
      boxShadow: "none",
      display: "inline-flex",
      alignItems: "center",
      gap: "3px",
      transition: "opacity 0.15s",
      fontWeight: 400
    }} onMouseOver={function (e) {
      e.currentTarget.style.opacity = "0.6";
    }} onMouseOut={function (e) {
      e.currentTarget.style.opacity = "0.4";
    }}>
              Edit in Playground
              <svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M15 3h6v6" /><path d="M10 14 21 3" /><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></svg>
            </a>
          </div>}
        <div style={{
      marginBottom: "-2rem"
    }}>{children}</div>
      </Card>;
  }
  return <Card>
      <div ref={cardRef} />
      <div ref={hostRef} />
      {playgroundHref && <div style={{
    display: "flex",
    justifyContent: "flex-end",
    padding: "4px 0 8px"
  }}>
          <a href={playgroundHref} target="_blank" rel="noopener noreferrer" style={{
    fontSize: "11px",
    opacity: 0.4,
    textDecoration: "none",
    textDecorationLine: "none",
    borderBottom: "none",
    boxShadow: "none",
    display: "inline-flex",
    alignItems: "center",
    gap: "3px",
    transition: "opacity 0.15s",
    fontWeight: 400
  }} onMouseOver={function (e) {
    e.currentTarget.style.opacity = "0.6";
  }} onMouseOut={function (e) {
    e.currentTarget.style.opacity = "0.4";
  }}>
            Edit in Playground
            <svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M15 3h6v6" /><path d="M10 14 21 3" /><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></svg>
          </a>
        </div>}
      <div style={{
    marginBottom: "-2rem"
  }}>{children}</div>
    </Card>;
};

Prefab's expression engine and action system cover most UI interactions declaratively. But some patterns — like sliders that sum to 100%, or domain-specific formatting — need custom client-side logic. Custom handlers let you write JavaScript functions that plug into the expression engine (as pipes) and the action dispatcher (as handlers), without breaking the protocol's declarative boundary.

Custom handlers are **host-level configuration**, not protocol. You write the JS as part of your app setup. The protocol references handlers by name only — the generated JSON never contains raw JavaScript.

## Custom Pipes

Register formatting functions that become available in `{{ }}` expressions. Each pipe receives the current value and an optional colon-separated argument, just like built-in pipes. Drag the slider to see the `stars` pipe update live:

<ComponentPreview json={{"view":{"cssClass":"pf-app-root","type":"Div","children":[{"cssClass":"gap-2","type":"Column","children":[{"cssClass":"items-center gap-4","type":"Row","children":[{"name":"rating","value":3.0,"type":"Slider","min":0.0,"max":5.0,"step":0.5,"disabled":false,"size":"default"},{"cssClass":"font-bold","content":"{{ rating | stars }}","type":"Text"}]}]}]},"state":{"rating":3.0}}} playground="ZnJvbSBwcmVmYWJfdWkuYXBwIGltcG9ydCBQcmVmYWJBcHAKZnJvbSBwcmVmYWJfdWkuY29tcG9uZW50cyBpbXBvcnQgQ29sdW1uLCBSb3csIFNsaWRlciwgVGV4dAoKanNfcGlwZXMgPSB7CiAgICAic3RhcnMiOiAiIiIodmFsdWUpID0-IHsKICAgICAgICBjb25zdCBuID0gTnVtYmVyKHZhbHVlKTsKICAgICAgICBjb25zdCBmdWxsID0gTWF0aC5mbG9vcihuKTsKICAgICAgICBjb25zdCBoYWxmID0gbiAlIDEgPj0gMC41ID8gMSA6IDA7CiAgICAgICAgY29uc3QgZW1wdHkgPSA1IC0gZnVsbCAtIGhhbGY7CiAgICAgICAgcmV0dXJuICfimIUnLnJlcGVhdChmdWxsKSArIChoYWxmID8gJ8K9JyA6ICcnKSArICfimIYnLnJlcGVhdChlbXB0eSk7CiAgICB9IiIiLAp9Cgp3aXRoIFByZWZhYkFwcChqc19waXBlcz1qc19waXBlcykgYXMgYXBwOgogICAgd2l0aCBDb2x1bW4oZ2FwPTIpOgogICAgICAgIHdpdGggUm93KGNzc19jbGFzcz0iaXRlbXMtY2VudGVyIGdhcC00Iik6CiAgICAgICAgICAgIFNsaWRlcihuYW1lPSJyYXRpbmciLCB2YWx1ZT0zLCBtaW49MCwgbWF4PTUsIHN0ZXA9MC41KQogICAgICAgICAgICBUZXh0KCJ7eyByYXRpbmcgfCBzdGFycyB9fSIsIGJvbGQ9VHJ1ZSkK">
  <CodeGroup>
    ```python Python icon="python" theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
    from prefab_ui.app import PrefabApp
    from prefab_ui.components import Column, Row, Slider, Text

    js_pipes = {
        "stars": """(value) => {
            const n = Number(value);
            const full = Math.floor(n);
            const half = n % 1 >= 0.5 ? 1 : 0;
            const empty = 5 - full - half;
            return '★'.repeat(full) + (half ? '½' : '') + '☆'.repeat(empty);
        }""",
    }

    with PrefabApp(js_pipes=js_pipes) as app:
        with Column(gap=2):
            with Row(css_class="items-center gap-4"):
                Slider(name="rating", value=3, min=0, max=5, step=0.5)
                Text("{{ rating | stars }}", bold=True)
    ```

    ```json Protocol icon="brackets-curly" theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
    {
      "view": {
        "cssClass": "pf-app-root",
        "type": "Div",
        "children": [
          {
            "cssClass": "gap-2",
            "type": "Column",
            "children": [
              {
                "cssClass": "items-center gap-4",
                "type": "Row",
                "children": [
                  {
                    "name": "rating",
                    "value": 3.0,
                    "type": "Slider",
                    "min": 0.0,
                    "max": 5.0,
                    "step": 0.5,
                    "disabled": false,
                    "size": "default"
                  },
                  {"cssClass": "font-bold", "content": "{{ rating | stars }}", "type": "Text"}
                ]
              }
            ]
          }
        ]
      },
      "state": {"rating": 3.0}
    }
    ```
  </CodeGroup>
</ComponentPreview>

The `tempColor` pipe adds contextual emoji based on the value:

<ComponentPreview json={{"view":{"cssClass":"pf-app-root","type":"Div","children":[{"cssClass":"gap-2","type":"Column","children":[{"cssClass":"items-center gap-4","type":"Row","children":[{"name":"temp","value":72.0,"type":"Slider","min":32.0,"max":120.0,"step":1.0,"disabled":false,"size":"default"},{"cssClass":"font-bold","content":"{{ temp | tempColor }}","type":"Text"}]}]}]},"state":{"temp":72.0}}} playground="ZnJvbSBwcmVmYWJfdWkuYXBwIGltcG9ydCBQcmVmYWJBcHAKZnJvbSBwcmVmYWJfdWkuY29tcG9uZW50cyBpbXBvcnQgQ29sdW1uLCBSb3csIFNsaWRlciwgVGV4dAoKanNfcGlwZXMgPSB7CiAgICAidGVtcENvbG9yIjogIiIiKHZhbHVlKSA9PiB7CiAgICAgICAgY29uc3QgZiA9IE51bWJlcih2YWx1ZSk7CiAgICAgICAgaWYgKGYgPj0gMTAwKSByZXR1cm4gZiArICfCsEYg8J-UpSc7CiAgICAgICAgaWYgKGYgPj0gODApIHJldHVybiBmICsgJ8KwRiDimIDvuI8nOwogICAgICAgIGlmIChmID49IDYwKSByZXR1cm4gZiArICfCsEYg8J-MpO-4jyc7CiAgICAgICAgcmV0dXJuIGYgKyAnwrBGIOKdhO-4jyc7CiAgICB9IiIiLAp9Cgp3aXRoIFByZWZhYkFwcChqc19waXBlcz1qc19waXBlcykgYXMgYXBwOgogICAgd2l0aCBDb2x1bW4oZ2FwPTIpOgogICAgICAgIHdpdGggUm93KGNzc19jbGFzcz0iaXRlbXMtY2VudGVyIGdhcC00Iik6CiAgICAgICAgICAgIFNsaWRlcihuYW1lPSJ0ZW1wIiwgdmFsdWU9NzIsIG1pbj0zMiwgbWF4PTEyMCwgc3RlcD0xKQogICAgICAgICAgICBUZXh0KCJ7eyB0ZW1wIHwgdGVtcENvbG9yIH19IiwgYm9sZD1UcnVlKQo">
  <CodeGroup>
    ```python Python icon="python" theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
    from prefab_ui.app import PrefabApp
    from prefab_ui.components import Column, Row, Slider, Text

    js_pipes = {
        "tempColor": """(value) => {
            const f = Number(value);
            if (f >= 100) return f + '°F 🔥';
            if (f >= 80) return f + '°F ☀️';
            if (f >= 60) return f + '°F 🌤️';
            return f + '°F ❄️';
        }""",
    }

    with PrefabApp(js_pipes=js_pipes) as app:
        with Column(gap=2):
            with Row(css_class="items-center gap-4"):
                Slider(name="temp", value=72, min=32, max=120, step=1)
                Text("{{ temp | tempColor }}", bold=True)
    ```

    ```json Protocol icon="brackets-curly" theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
    {
      "view": {
        "cssClass": "pf-app-root",
        "type": "Div",
        "children": [
          {
            "cssClass": "gap-2",
            "type": "Column",
            "children": [
              {
                "cssClass": "items-center gap-4",
                "type": "Row",
                "children": [
                  {
                    "name": "temp",
                    "value": 72.0,
                    "type": "Slider",
                    "min": 32.0,
                    "max": 120.0,
                    "step": 1.0,
                    "disabled": false,
                    "size": "default"
                  },
                  {"cssClass": "font-bold", "content": "{{ temp | tempColor }}", "type": "Text"}
                ]
              }
            ]
          }
        ]
      },
      "state": {"temp": 72.0}
    }
    ```
  </CodeGroup>
</ComponentPreview>

The `initials` pipe extracts first letters. Type a name to see it update:

<ComponentPreview json={{"view":{"cssClass":"pf-app-root","type":"Div","children":[{"cssClass":"gap-2","type":"Column","children":[{"name":"full_name","value":"Arthur Dent","type":"Input","inputType":"text","placeholder":"Type a name...","disabled":false,"readOnly":false,"required":false},{"cssClass":"items-center gap-2","type":"Row","children":[{"content":"Initials:","type":"Text"},{"cssClass":"font-bold","content":"{{ full_name | initials }}","type":"Text"}]}]}]},"state":{"full_name":"Arthur Dent"}}} playground="ZnJvbSBwcmVmYWJfdWkuYXBwIGltcG9ydCBQcmVmYWJBcHAKZnJvbSBwcmVmYWJfdWkuY29tcG9uZW50cyBpbXBvcnQgQ29sdW1uLCBJbnB1dCwgUm93LCBUZXh0Cgpqc19waXBlcyA9IHsKICAgICJpbml0aWFscyI6ICIiIih2YWx1ZSkgPT4gewogICAgICAgIHJldHVybiBTdHJpbmcodmFsdWUpLnNwbGl0KCcgJykubWFwKHcgPT4gd1swXSkuam9pbignJykudG9VcHBlckNhc2UoKTsKICAgIH0iIiIsCn0KCndpdGggUHJlZmFiQXBwKGpzX3BpcGVzPWpzX3BpcGVzKSBhcyBhcHA6CiAgICB3aXRoIENvbHVtbihnYXA9Mik6CiAgICAgICAgSW5wdXQobmFtZT0iZnVsbF9uYW1lIiwgdmFsdWU9IkFydGh1ciBEZW50IiwgcGxhY2Vob2xkZXI9IlR5cGUgYSBuYW1lLi4uIikKICAgICAgICB3aXRoIFJvdyhjc3NfY2xhc3M9Iml0ZW1zLWNlbnRlciBnYXAtMiIpOgogICAgICAgICAgICBUZXh0KCJJbml0aWFsczoiKQogICAgICAgICAgICBUZXh0KCJ7eyBmdWxsX25hbWUgfCBpbml0aWFscyB9fSIsIGJvbGQ9VHJ1ZSkK">
  <CodeGroup>
    ```python Python icon="python" theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
    from prefab_ui.app import PrefabApp
    from prefab_ui.components import Column, Input, Row, Text

    js_pipes = {
        "initials": """(value) => {
            return String(value).split(' ').map(w => w[0]).join('').toUpperCase();
        }""",
    }

    with PrefabApp(js_pipes=js_pipes) as app:
        with Column(gap=2):
            Input(name="full_name", value="Arthur Dent", placeholder="Type a name...")
            with Row(css_class="items-center gap-2"):
                Text("Initials:")
                Text("{{ full_name | initials }}", bold=True)
    ```

    ```json Protocol icon="brackets-curly" theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
    {
      "view": {
        "cssClass": "pf-app-root",
        "type": "Div",
        "children": [
          {
            "cssClass": "gap-2",
            "type": "Column",
            "children": [
              {
                "name": "full_name",
                "value": "Arthur Dent",
                "type": "Input",
                "inputType": "text",
                "placeholder": "Type a name...",
                "disabled": false,
                "readOnly": false,
                "required": false
              },
              {
                "cssClass": "items-center gap-2",
                "type": "Row",
                "children": [
                  {"content": "Initials:", "type": "Text"},
                  {
                    "cssClass": "font-bold",
                    "content": "{{ full_name | initials }}",
                    "type": "Text"
                  }
                ]
              }
            ]
          }
        ]
      },
      "state": {"full_name": "Arthur Dent"}
    }
    ```
  </CodeGroup>
</ComponentPreview>

Custom pipes follow the same rules as built-in ones — they're synchronous, pure functions. Built-in pipes always take priority, so you can't accidentally shadow `number` or `currency`.

## Custom Action Handlers

Register functions that transform state in response to events. Handlers receive a context object with the current state snapshot and the triggering event, and return an object of state updates to merge.

```python theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
from prefab_ui.app import PrefabApp
from prefab_ui.actions import CallHandler
from prefab_ui.components import Slider

js_actions = {
    "constrainBudget": """(ctx) => {
        const keys = ['infra', 'people', 'tools'];
        const changed = ctx.arguments.key;
        const newVal = ctx.event;
        const others = keys.filter(k => k !== changed);
        const remaining = 100 - newVal;
        const otherTotal = others.reduce((s, k) => s + ctx.state[k], 0);
        const updates = {};
        for (const k of others) {
            updates[k] = otherTotal > 0
                ? Math.round((ctx.state[k] / otherTotal) * remaining)
                : Math.round(remaining / others.length);
        }
        return updates;
    }""",
}

# Pass the slider's key so the handler knows which changed:
with PrefabApp(js_actions=js_actions) as app:
    Slider(
        name="infra",
        on_change=CallHandler("constrainBudget", arguments={"key": "infra"}),
    )
```

The handler context has three fields:

| Field           | Description                                                                                               |
| --------------- | --------------------------------------------------------------------------------------------------------- |
| `ctx.state`     | Snapshot of the full state at the time the handler fires. Read-only — return updates instead of mutating. |
| `ctx.event`     | The `$event` value from the triggering interaction (slider value, click event, etc.).                     |
| `ctx.arguments` | Optional extra arguments from the `CallHandler` action spec.                                              |

The return value is merged into state using the same mechanism as `SetState`. If the handler returns nothing (void/undefined), no state changes happen.

## CallHandler Action

`CallHandler` is the action that invokes a registered handler. It mirrors `CallTool` — where `CallTool` calls a server-side MCP tool, `CallHandler` calls a client-side JavaScript function.

```python theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
from prefab_ui.actions import CallHandler

# Basic usage
Button(on_click=CallHandler("refresh"))

# With extra arguments
Button(on_click=CallHandler(
    "processData",
    arguments={"format": "csv"},
))

# Chained with other actions
Button(on_click=[
    SetState("loading", True),
    CallHandler("validate"),
])
```

The handler's return value is available as `$result` in `onSuccess` callbacks, just like `CallTool`:

```python theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
Button(on_click=CallHandler(
    "compute",
    on_success=SetState("result", "{{ $result.total }}"),
    on_error=ShowToast("Computation failed", variant="error"),
))
```

## Example: Linked Sliders

Three budget sliders constrained to sum to 100%. Moving one redistributes the others proportionally. This is the kind of client-side coordination that expressions can't do — you need a function that knows about all the sliders and can compute the redistribution.

```python theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
from prefab_ui.app import PrefabApp
from prefab_ui.actions import CallHandler
from prefab_ui.components import Card, CardContent, Column, Row, Slider, Text

js_actions = {
    "constrain": """(ctx) => {
        const keys = ['infra', 'people', 'tools'];
        const changed = ctx.arguments.key;
        const newVal = ctx.event;
        const others = keys.filter(k => k !== changed);
        const remaining = 100 - newVal;
        const otherTotal = others.reduce((s, k) => s + ctx.state[k], 0);
        const updates = {};
        for (const k of others) {
            updates[k] = otherTotal > 0
                ? Math.round((ctx.state[k] / otherTotal) * remaining)
                : Math.round(remaining / others.length);
        }
        return updates;
    }""",
}

with PrefabApp(js_actions=js_actions) as app:
    with Card():
        with CardContent():
            with Column(gap=4):
                for label, key in [
                    ("Infrastructure", "infra"),
                    ("People", "people"),
                    ("Tools", "tools"),
                ]:
                    with Column(gap=1):
                        with Row(css_class="justify-between"):
                            Text(label)
                            Text(f"{{{{ {key} | round }}}}%", bold=True)
                        Slider(
                            name=key, max=100, step=1,
                            on_change=CallHandler(
                                "constrain",
                                arguments={"key": key},
                            ),
                        )

                with Row(css_class="justify-between pt-4 border-t"):
                    Text("Total", bold=True)
                    Text("{{ infra + people + tools | round }}%", bold=True)
```

Run it with `uv run python examples/linked_sliders.py` to see it in action. The handler runs client-side on every drag — no server round-trip, no lag.

## API Reference

<Card icon="code" title="PrefabApp Fields">
  <ParamField body="js_pipes" type="dict[str, str] | None" default="None">
    Custom pipe functions. Keys are pipe names, values are JavaScript function expressions. Each function receives `(value, arg?)` and returns the transformed value.
  </ParamField>

  <ParamField body="js_actions" type="dict[str, str] | None" default="None">
    Custom action handlers. Keys are handler names, values are JavaScript function expressions. Each function receives `(ctx)` with `{state, event, arguments}` and returns state updates to merge.
  </ParamField>
</Card>

<Card icon="code" title="CallHandler Parameters">
  <ParamField body="handler" type="str" required>
    Name of the registered handler function.
  </ParamField>

  <ParamField body="arguments" type="dict | None" default="None">
    Extra arguments passed to the handler via `ctx.arguments`. Supports template expressions.
  </ParamField>
</Card>

## Protocol Reference

```json CallHandler theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
{
  "action": "callHandler",
  "handler": "string (required)",
  "arguments?": "object"
}
```

For the complete protocol schema, see [CallHandler](/protocol/call-handler).
