social components inlay-proto.up.railway.app/
atproto components sdui
TypeScript 97.2%
CSS 2.5%
JavaScript 0.2%
Other 0.1%
26 1 0

Clone this repository

https://tangled.org/danabra.mov/inlay https://tangled.org/did:plc:fpruhuo22xkm5o7ttr2ktxdo/inlay
git@tangled.org:danabra.mov/inlay git@tangled.org:did:plc:fpruhuo22xkm5o7ttr2ktxdo/inlay

For self-hosted knots, clone URLs may differ based on your setup.

Download tar.gz
README.md

Inlay#

Inlay lets you build social UIs from components that live on the AT Protocol.

For example, consider this tree of Inlay components:

<com.pfrazee.ProfileCard uri="at://did:plc:ragtjsm2j2vknwkz3zp4oxrd/app.bsky.actor.profile/self">
  <mov.danabra.FollowCount uri="at://did:plc:ragtjsm2j2vknwkz3zp4oxrd" />
  <org.atsui.Stack gap="small">
    <org.atsui.Avatar src={profile.avatar} />
    <org.atsui.Text>{profile.displayName}</org.atsui.Text>
  </org.atsui.Stack>
</com.pfrazee.ProfileCard>

A Inlay component like ProfileCard or FollowCount is just a name (NSID) — anyone can publish an implementation for it, and a host (i.e. a browser for Inlay) picks which implementation to use, potentially taking both developer and user preferences into account.

Here, three different authors' components work together:

  • ProfileCard (com.pfrazee.ProfileCard) might be a template stored as a record
  • FollowCount (mov.danabra.FollowCount) might be an XRPC procedure on someone's server
  • Stack, Avatar, and Text (org.atsui.*) are primitives that the host renders directly. The host resolves each one until everything bottoms out in primitives, then turns those into HTML, React, SwiftUI, or whatever it wants.

Demo: https://inlay-proto.up.railway.app/ (source)

Packages#

Package Description
@inlay/core Element trees, JSX runtime, and serialization
@inlay/render Server-side component resolution
@inlay/cache Cache policy for XRPC component handlers

How it works#

Each component is an at.inlay.component record with one of three body kinds:

  • No body — a primitive. The host renders it directly.
  • Template — a stored element tree with Binding placeholders that get filled in with props.
  • External — an XRPC endpoint that receives props and returns an element tree.

Each component also imports a list of packs. A pack maps NSIDs to component URIs — it's how the host knows which implementation to use.

Atsui (org.atsui) is the first design system for Inlay — including <Text>, <Avatar>, <List>, and others. In a sense, it's like Inlay's HTML. Atsui is not a traditional component library because it solely defines lexicons (i.e. interfaces). Each host (an Inlay browser like this demo) may choose which components are built-in, and how they work. A host could even decide to not implement Atsui, and instead to implement another set of primitives.

A host chooses its own tech stack. For example, you could write a host with Hono and htmx, or with React Server Components, or even with SwiftUI and a custom server-side JSON endpoint. The resolution algorithm is shared, but each host may interpret the final tree as it wishes.

Declaring components#

There is no visual editor yet. Components are created by writing records to your PDS.

Pick an NSID#

An NSID describes what a component does, not how. If someone already defined one that fits (e.g. com.pfrazee.BskyPost with a uri prop), you can implement it yourself. Multiple implementations of the same NSID can coexist — packs control which one gets used.

If you're creating a new NSID, pick one under a domain you control so you can publish a Lexicon for it later.

Component record#

This is the record you write to your PDS.

Template#

The element tree lives inside the record. Bindings are placeholders that get filled in with props at render time.

{
  "$type": "at.inlay.component",
  "type": "mov.danabra.Greeting",
  "body": {
    "$type": "at.inlay.component#bodyTemplate",
    "node": {
      "$": "$", "type": "org.atsui.Stack", "props": {
        "gap": "small",
        "children": [
          {
            "$": "$",
            "type": "org.atsui.Text",
            "props": { "children": ["Hello, "] },
            "key": "0"
          },
          {
            "$": "$",
            "type": "org.atsui.Text",
            "props": { "children": [ { "$": "$", "type": "at.inlay.Binding", "props": { "path": ["name"] } } ] },
            "key": "1"
          }
        ]
      }
    }
  },
  "imports": [
    "at://did:plc:mdg3w2kpadcyxy33pizokzf3/at.inlay.pack/3me7vlpfkcj25",
    "at://did:plc:e4fjueijznwqm2yxvt7q4mba/at.inlay.pack/3me3pkxzcf22m"
  ]
}

The imports array lists the packs your component needs — typically the Atsui pack so you can use org.atsui.* primitives.

When rendered as <mov.danabra.Greeting name="world" />, this component resolves to:

<org.atsui.Stack gap="small">
  <org.atsui.Text>Hello</org.atsui.Text>
  <org.atsui.Text>world</org.atsui.Text>
</org.atsui.Stack>

External#

As an alternative to templates (which are very limited), you can declare components as XRPC server endpoints. Then the Inlay host will hit your endpoint to resolve the component tree.

Here's a real external component record for mov.danabra.Greeting. Notice its body points to a service did instead of an inline node:

{
  "$type": "at.inlay.component",
  "type": "mov.danabra.Greeting",
  "body": {
    "$type": "at.inlay.component#bodyExternal",
    "did": "did:web:gaearon--019c954e8a7877ea8d8d19feb1d4bbbb.web.val.run"
  },
  "imports": [
    "at://did:plc:mdg3w2kpadcyxy33pizokzf3/at.inlay.pack/3me7vlpfkcj25",
    "at://did:plc:e4fjueijznwqm2yxvt7q4mba/at.inlay.pack/3me3pkxzcf22m"
  ]
}

The did is a did:web, so the host resolves it by fetching /.well-known/did.json to find the service endpoint, then POSTs props to /xrpc/mov.danabra.Greeting and gets back { node, cache }.

This particular handler runs on Val Town:

/* @jsxImportSource npm:@inlay/core@0.0.13 */
import { component, serve } from "https://esm.town/v/gaearon/inlay/main.ts";
import { Stack, Text } from "https://lex.val.run/org.atsui.*.ts";

component("mov.danabra.Greeting", ({ name }) => (
  <Stack gap="small">
    <Text>Hello,</Text>
    <Text>{name || "stranger"}</Text>
  </Stack>
));

export default serve;

It's recommended to tag XRPC return values as cacheable; see @inlay/cache for how to do this. In the calling code, it's recommended to wrap XRPC components into <at.inlay.Loading fallback=...> so that they don't block the entire page.

Pack record#

To let other components use yours, add it to a pack. Here's the danabra.mov/ui pack:

{
  "$type": "at.inlay.pack",
  "name": "ui",
  "exports": [
    { "type": "mov.danabra.Greeting", "component": "at://did:plc:fpruhuo22xkm5o7ttr2ktxdo/at.inlay.component/3mfoxe7h4fsj6" },
    ...
  ]
}

Other components import this pack URI in their imports array to get access to mov.danabra.Greeting.

Lexicon (optional)#

A Lexicon defines the prop schema for an NSID. You don't need one to get started, but publishing one enables type checking, validation, and codegen with @atproto/lex:

{
  "lexicon": 1,
  "id": "mov.danabra.Greeting",
  "defs": {
    "main": {
      "type": "procedure",
      "input": {
        "encoding": "application/json",
        "schema": {
          "type": "object",
          "required": ["name"],
          "properties": {
            "name": { "type": "string", "maxLength": 100 }
          }
        }
      },
      "output": {
        "encoding": "application/json",
        "schema": { "type": "ref", "ref": "at.inlay.defs#response" }
      }
    }
  }
}

See @inlay/core for JSX support and more ways to build element trees.

Rendering as a host#

A host walks an element tree, resolves each component through its packs, and maps the resulting primitives to output (HTML, React, etc).

@inlay/render does the resolution step — call render() on an element, get back the expanded tree or a primitive. The host calls it in a loop until everything is resolved. The render README has a minimal working example that turns Inlay elements into HTML.

If you're writing XRPC component handlers, @inlay/cache lets you declare cache lifetime and invalidation tags (cacheLife(), cacheTagRecord(), cacheTagLink()). The host uses these to know when to re-render.

proto/ is a simple but complete host with streaming, caching, and prefetching.

Development#

npm install
npm run build       # generate lexicons + build all packages
npm run typecheck   # type-check everything
npm test            # run tests

Project structure#

packages/@inlay/core/     — element primitives and JSX
packages/@inlay/render/   — rendering engine
packages/@inlay/cache/    — cache declarations
proto/                    — prototype host server (Hono)
invalidator/              — cache invalidation service
lexicons/                 — AT Protocol lexicon definitions
generated/                — generated TypeScript from lexicons

License#

MIT