# Intertext
[Try the studio](https://intertext.dev) · [Integration guide](https://intertext.dev/docs/integration.md) · [Report an issue](https://github.com/davidezhang/intertext/issues)
A responsive image, GIF, audio waveform, or silent looping video that sits between words. The studio is a full-screen canvas that defaults to dark mode, with a single collapsible control sidebar. The reusable pill is a dependency-free **Web Component**; React bindings support semantic static compositions, with separate opt-in placement and editing controls.
## Add it to your website
Start with the [integration guide](https://intertext.dev/docs/integration.md). It includes native HTML, React/Next.js, media handling, and complete [editorial](https://intertext.dev/examples/editorial.html), [portfolio](https://intertext.dev/examples/portfolio.html), [product](https://intertext.dev/examples/product.html), [event](https://intertext.dev/examples/event.html), [studio](https://intertext.dev/examples/studio.html), and [field notes](https://intertext.dev/examples/notes.html) recipes. Normal website compositions use real headings and paragraphs with static pills; dragging is for editing experiences.
Install the Intertext skill from your website project:
```sh
npx skills add davidezhang/intertext
```
Then ask your agent to add Intertext inline media to your existing page. The skill handles integration guidance; the website uses the component below. The website serves the same skill too, with `npx skills add https://intertext.dev`; see the [hosted skill instructions](https://intertext.dev/.well-known/agent-skills/intertext/SKILL.md).
**Distribution:** [intertext-dev](https://www.npmjs.com/package/intertext-dev) is available on npm. The website also provides the same `0.1.1` package through an immutable versioned download.
## Free core and future composition packs
The component, React bindings, and current studio are free under the [MIT License](LICENSE), including for commercial projects. You may modify, distribute, and sell copies under MIT's terms; retain its copyright and permission notice. Code export is included in the free studio.
The first planned paid offering is a separate, one-time-purchase collection of designed compositions, hero sections, and motion presets. Premium packs will be distributed separately with their own commercial licenses. No premium pack is currently available, and nothing in this repository requires a purchase.
Third-party dependencies retain their own licenses. The demo photos, GIFs, videos, and posters in `public/media` are covered by their original Unsplash or Pexels licenses, not MIT; see [Demo asset credits](#demo-asset-credits) and the [media license notice](https://intertext.dev/media/LICENSE.md).
Instrument Serif in `public/fonts` is licensed under the [SIL Open Font License](public/fonts/InstrumentSerif-OFL.txt). Its license is included when exporting a composition that uses it.
## Run the studio
Use Node 20.19+ or 22.12+ and the pnpm version declared in `package.json`:
```sh
git clone https://github.com/davidezhang/intertext.git
cd intertext
pnpm install
pnpm dev
```
Open the local URL printed by Vite. `pnpm dev` first prepares the reusable release and examples. `pnpm build` builds the studio (`dist/site`) and reusable library (`dist/lib`), including versioned downloads under `public/releases/0.1.1`. `pnpm build:release` builds only the reusable release and example assets. `pnpm check:release` runs the full build, test suite, and a clean-consumer package/SSR smoke check; `pnpm test` runs the tests against an existing build.
For bug reports and contributions, see [CONTRIBUTING.md](CONTRIBUTING.md). Include a minimal reproduction and the browser/framework you used when reporting an integration problem.
The canvas always fills the window. **Click the text to edit it in place.** Typing, selection, plain-text paste, line breaks, and undo/redo work directly on the canvas. Pills stay protected while you edit, and their placement follows the surrounding words. Remove pills through their settings. Escape leaves text editing.
Deleting the last words clears leftover spaces and browser line-break fillers. An empty canvas has no placeholder: click before, between, or after the remaining media pills to start typing at that position. Blank inline caret targets keep the canvas editable without adding text to copies or exports.
The icon-only sliders button opens **global settings**: Typography, Canvas, and Export. Typography controls type scale; there is no separate text field. Click a pill (or focus it and press Enter) to open its own Media, Shape, Motion, and placement settings in the same right-hand sidebar. Each pill has independent media, sizing, fitting, and animation. Escape or a click on the canvas closes settings. Keyboard shortcuts T, C, E open the corresponding global section when you are not editing text.
The **+** button adds a pill at a random word boundary, preferring an unoccupied location. It brings the new pill into view and focuses it so it is ready to drag or move with the arrow keys. Dragging keeps settings closed. Remove a pill from its settings footer. Canvas contains dark/light backgrounds and fluid/375-pixel preview settings; Export includes every pill in the composition. Reset global settings restores the default typeface, type scale, dark mode, and fluid canvas without changing the text or pills. **Start over**, beside it, asks for confirmation and then replaces the text, pills, and settings with the starter composition.
The **UI sounds** toggle in Global settings remembers your choice in this browser. Sounds start with your first interaction: soft thumps for adding/moving pills, opening controls, and playing an animation; short ticks for text edits, slider changes, selections, and export actions. These use the demo film's synthesized noise and bass textures at a restrained volume. Rapid adjustments are rate-limited, muting stops active sounds, and background tabs stay quiet. There is no background music. UI feedback is local to the studio. Audio pills play only on request.
Shape and Motion use [DialKit](https://joshpuckett.me/dialkit) controls embedded in the sidebar. Drag a slider to adjust it, use the arrow keys for fine changes, or focus it and press Enter to type a precise value. Width and height use `em` (relative to the surrounding text); radius uses pixels. Motion shows both starting and target dimensions, with current width and height synchronized with Shape, plus an interactive Bézier editor. **Easing** uses duration and a curve; **Time** uses visual duration and bounce; **Physics** uses stiffness, damping, and mass. Play, hover, and loop use the selected transition. Springs include their settling tail, so their total playback time can exceed the visual duration. Exports keep loops and add a Pause animation button; hover-expanding pills in exports also toggle when tapped or activated with Enter or Space.
Upload media stays in your browser using an object URL. URL media is fetched directly from the URL you enter. The studio autosaves the composition (text, pills, typography, theme, and preview width) in this browser, so a reload keeps your work. Uploaded files are not saved: after a reload those pills show a striped placeholder until you upload the file again, and exports wait until you do. If the browser blocks storage, the studio still works without autosave; download a kit to keep a copy.
To revise a downloaded kit, choose **Open composition** in Export and select the kit's `composition.json`. It replaces the current composition. Studio samples and linked media come back automatically; upload your own files again from the kit's `media/` folder (`public/media/` in a React kit). Files are read as data: unknown fields are ignored, values are limited to the studio's ranges, and only http and https media URLs are kept.
## Any website: native HTML
Load `https://intertext.dev/releases/0.1.1/artifact-pill.js` directly. For self-hosting, copy the generated `public/releases/0.1.1/artifact-pill.js` and its `LICENSE` into your project, retaining the MIT notice. The module has no React, animation, or CSS dependencies, and importing it registers the element once. Maintainers can also use locally built files.
```html
<script type="module" src="https://intertext.dev/releases/0.1.1/artifact-pill.js"></script>
<h1 style="font-size: clamp(2rem, 7vw, 8rem); line-height: 1.2">
Designing
<artifact-pill
src="/media/image.jpg"
alt="Angular concrete beams and recessed windows"
width="3.3em"
height="0.85em"
radius="999px"
fit="crop"
expanded-width="4.5em"
expanded-height="1.1em"
></artifact-pill>
text interfaces for new forms of computing.
</h1>
```
The [plain HTML example](https://intertext.dev/examples/plain-html.html) uses only HTML and the compiled element. Maintainers can also open `examples/plain-html.html` through the dev server after building. The same custom element can be used from Vue, Svelte, or another framework. Follow that framework's custom-element configuration if needed.
## React
Install the package:
```sh
npm install intertext-dev
```
For maintainers with a local source checkout:
```sh
pnpm build:release
# From the consuming React project:
pnpm add /absolute/path/to/intertext/public/releases/0.1.1/intertext-dev-0.1.1.tgz
```
Use your project's package manager and retain the lockfile. You can also install the [versioned website archive](https://intertext.dev/releases/0.1.1/intertext-dev-0.1.1.tgz). The npm package named `intertext` is unrelated.
React and React DOM are optional peers, required only by the React entry point. The package includes TypeScript declarations. The React bundle preserves `use client` for frameworks using React Server Components. Browser registration occurs in the client bundle; imports are safe during server rendering.
For a static inline pill:
```tsx
import { ArtifactPill } from 'intertext-dev/react';
export function Heading() {
return (
<h1>
Designing{' '}
<ArtifactPill
src="/media/clip.mp4"
kind="video"
alt="A monochrome ascent through a steel and glass escalator"
poster="/media/poster.jpg"
paused
width="3.3em"
height="0.85em"
fit="crop"
/>{' '}
text interfaces for new forms of computing.
</h1>
);
}
```
For an explicitly requested placement editor, enable dragging between words. The following wrappers render a `div`; use `ArtifactPill` inside a real `h1` or `p` for a published static composition:
```tsx
'use client';
import { useState } from 'react';
import { InlineArtifactText } from 'intertext-dev/react';
export function EditableHeading() {
const [position, setPosition] = useState(1);
return (
<InlineArtifactText
text="Designing text interfaces for new forms of computing."
position={position}
onPositionChange={setPosition}
movable
style={{ fontSize: 'clamp(2rem, 7vw, 8rem)', lineHeight: 1.2 }}
artifact={{
src: '/media/photo.jpg',
alt: 'Angular concrete beams and recessed windows',
width: '3.3em',
height: '.85em',
fit: 'crop',
duration: '650ms',
hoverExpand: true,
expandedWidth: '5em',
expandedHeight: '1.1em',
}}
/>
);
}
```
`position` is the boundary before word N: zero is the beginning, and `words.length` is the end. `InlineArtifactText` manages one pill per text block. For multiple pills, use `InlineArtifactsText` with stable IDs:
```tsx
import { useState } from 'react';
import { InlineArtifactsText, type InlineArtifactItem } from 'intertext-dev/react';
export function Composition() {
const [artifacts, setArtifacts] = useState<InlineArtifactItem[]>([
{ id: 'concrete', position: 1, artifact: { src: '/concrete.jpg', alt: 'Angular concrete beams', width: '3.3em' } },
{ id: 'architecture', position: 4, artifact: { src: '/escalator-motion.mp4', kind: 'video', alt: 'A monochrome ascent through a steel and glass escalator', width: '2em' } },
]);
return <InlineArtifactsText
text="Designing text interfaces for new forms of computing."
artifacts={artifacts}
movable
onPositionChange={(id, position) => setArtifacts(items =>
items.map(item => item.id === id ? { ...item, position } : item)
)}
/>;
}
```
`onSelectArtifact(id)` handles a click or Enter/Space activation, independently of dragging. `selectedId` marks the selected pill; `onArtifactDragStart(id)` can close an editor while dragging. Pills at the same boundary retain array order. Updating one item preserves the other pills and their media nodes. The original single-pill API remains compatible.
To enable in-place editing, give `InlineArtifactsText` an `onTextChange` callback and control the text as well as the artifacts:
```tsx
onTextChange={(text, positions) => {
setText(text);
setArtifacts(items => items.map(item => ({
...item, position: positions[item.id],
})));
}}
```
The callback provides the updated plain text and pill positions together. Selection replacement and cut preserve the pills, paste strips formatting, and Enter inserts a line break. Text undo/redo works within an editing session; external changes to the composition, such as adding or dragging a pill, start a new undo history. Omit `onTextChange` for a display-only text composition.
Both wrappers are controlled plain-text compositions, not rich-text editors; they default to movable and drag is limited to the current block. Choose them only for visitor-facing placement/editing, or pass `movable={false}` for a display-only wrapper. Pointer events support mouse, pen, and touch. Focus a pill and use Left/Right or Home/End to move it. Escape cancels an active drag; releasing outside the block cancels placement. Position changes are announced to assistive technology.
While dragging, the pill follows the exact point you grabbed and the words reflow immediately around its new position. The same media element stays mounted. Interruptible springs soften the movement of words and the pill's release; there is no insertion cursor or duplicate drag image. `onPositionChange` fires once on a valid release, so cancellation restores the original placement without updating your state. Reduced motion keeps direct pointer tracking and skips the settling animation.
## Pill API
| HTML attribute | React prop | Behavior |
| --- | --- | --- |
| `src` | `src` | Image, GIF, video, or audio URL |
| `kind` | `kind` | `image` (default), `gif`, `video`, or `audio`; specify the media type explicitly |
| `alt` | `alt` | Media description; empty for decorative images |
| `width`, `height` | same | Any CSS length, such as `em`, `px`, `%`, or `clamp()` |
| `radius` | `radius` | CSS border radius; defaults to `999px` |
| `fit` | `fit` | `crop` → `cover`; `fit` → `contain`; `fill` → stretched |
| `position` | `position` | CSS object-position, e.g. `60% 30%` |
| `duration` | `duration` | CSS duration, e.g. `650ms` |
| `easing` | `easing` | CSS timing function, `cubic-bezier()`, or `linear()` spring curve |
| `expanded` | `expanded` | Use the expanded size |
| `hover-expand` | `hoverExpand` | Expand on hover or visible keyboard focus; tap, click, Enter, or Space toggles `expanded` |
| `expanded-width` | `expandedWidth` | Expanded width; default `4.2em` |
| `expanded-height` | `expandedHeight` | Expanded height; default `1.25em` |
| `loop` | `loop` | Alternate between the base and expanded sizes; remove it to pause |
| `poster` | `poster` | Video poster image |
| `paused` | `paused` | Pause audio, video, or show a GIF still; omit to allow video/GIF autoplay |
| `play-on-hover` | `playOnHover` | Play audio, video, or GIF while hovered; takes precedence over `paused` |
The pill is `inline-block`, baseline aligned, and capped at `max-width:100%` of its containing block. `em` values scale with the surrounding type. Changing width, height, or radius triggers a CSS transition; text naturally reflows as dimensions change. During size transitions, line breaks move at their actual wrapping threshold. The React wrapper animates individual words when drag or keyboard placement changes. Pointer hover remains active across reflow until the pointer moves outside the area spanning the pill's original and current bounds. Layout-generated enter/leave events cannot restart expansion or activate a sibling under a stationary pointer. Leaving the page, scrolling, resizing, dragging, or disabling hover clears the interaction; keyboard focus and explicit expansion remain independent.
Use CSS variables for defaults: `--artifact-width`, `--artifact-height`, `--artifact-radius`, `--artifact-align`, `--artifact-background`, `--artifact-focus`, `--artifact-duration`, `--artifact-easing`, `--artifact-expanded-width`, and `--artifact-expanded-height`. Explicit attributes take precedence. The shadow media element exposes `::part(media)` for custom styling.
Change the size from application state or plain JavaScript:
```js
const pill = document.querySelector('artifact-pill');
pill.setAttribute('width', '5em');
pill.setAttribute('height', '1.1em');
// Or toggle a predefined expanded state:
pill.toggleAttribute('expanded');
```
## Media and accessibility
- Video is muted, inline, and looping. `paused` stops it. Browser autoplay policies may still block playback; supply a poster.
- Reduced motion disables pill transitions, video autoplay, and `loop`. The studio also disables hover expansion under reduced motion.
- Touch has no hover, so a `hover-expand` pill is also a toggle button: a tap, a click, or Enter or Space while focused pins `expanded`, and `role="button"`, `aria-expanded`, and an `aria-label` from `alt` describe it. Add `tabindex="0"` for keyboard access. A `role` set by your page, or an enclosing link or button, keeps its own behavior instead.
- A `loop` that runs longer than five seconds needs a visible way to pause it (WCAG 2.2.2): toggle the attribute from a button. Studio exports include one.
- Paused GIFs display a still canvas preview. Hover playback reveals the animated image while hovered; GIFs do not resume at an exact timeline position. Reduced motion keeps the still preview.
- Supply meaningful media descriptions. The studio lets you edit them in Media. Interactive pills expose movement instructions and a live region; static images expose alt text.
- Failed media shows an inline fallback and emits an `artifact-error` event. Supported video codecs depend on the visitor's browser; MP4/H.264 and WebM are the recommended formats.
- The native element can be used in any normal inline layout. The React wrapper's `movable={false}` disables its drag/keyboard behavior.
## Implementation and verification
The component uses the browser's native inline layout, Shadow DOM, CSS object-fit, CSS transitions, and pointer events. Pretext was considered, but custom text measurement is unnecessary for this flow. The studio embeds DialKit's exported sliders, toggles, folders, and transition editor in its custom sidebar. Pill values remain in React state; a scoped DialKit store registration keeps the transition editor mode in sync and is removed on unmount. Media tools, inline editing, placement, and component export remain custom.
DialKit and Motion are development dependencies used by the studio build only. Motion compiles springs into CSS `linear()` timing functions, which are also included in exported snippets; the reusable `dist/lib` entries import neither library. Spring snippets require a browser with CSS `linear()` support. The library's existing drag springs are independent of the studio's size-animation editor.
`pnpm test` verifies package imports without DOM globals, SSR attributes, false boolean handling, placement boundaries, safe text escaping/Unicode, movement accessibility markup, and the React client directive. DOM interaction tests use a deterministic wrapping layout to verify reflow before release, media identity, grab offset, pointer capture, reversal, single commit, cancellation, keyboard movement, and reduced motion. Multi-pill checks cover independent dragging, click versus drag selection, editing/removal, shared boundaries, and random insertion. Inline editing checks cover caret continuity, pill placement, selection replacement, undo/redo, empty text, plain-text paste, line breaks, and IME composition. Browser verification covers pointer dragging across wrapped lines, media rendering, responsive layouts, full-screen geometry, in-place editing, and global/per-pill controls. Browser-specific autoplay and codecs remain subject to the host browser.
The studio's interaction and material refinements follow the [Apple design skill](https://www.ui-skills.com/skills/emilkowalski/apple-design): direct manipulation, interruptible settling, anchored sidebar transitions, readable system typography, and reduced motion/transparency/contrast preferences.
## Demo asset credits
- **Concrete study** — [Marc Cordeau on Unsplash](https://unsplash.com/photos/Z3MsCdxNJg4), under the [Unsplash License](https://unsplash.com/license). Resized photograph of angular concrete architecture.
- **Analog loop** — [emirkhan bal on Pexels](https://www.pexels.com/video/spinning-vinyl-record-7202466/), under the [Pexels License](https://www.pexels.com/license/). A four-second monochrome GIF excerpt of a spinning turntable, with a still poster.
- **Escalator study** — [Therese B. on Pexels](https://www.pexels.com/video/black-and-white-urban-escalator-scene-32250489/), under the [Pexels License](https://www.pexels.com/license/). An eight-second monochrome, silent escalator excerpt with moving steps, rails, and reflections, plus a still poster.
- **Concrete in motion (legacy asset)** — [Nuray on Pexels](https://www.pexels.com/video/elegant-brutalist-architecture-against-blue-sky-28883887/), under the [Pexels License](https://www.pexels.com/license/). An eight-second monochrome, silent architectural video excerpt, with a still poster.
- **Shell chair** — [Resource Database on Unsplash](https://unsplash.com/photos/rZ1RPEom9gs), under the [Unsplash License](https://unsplash.com/license). A resized monochrome chair render from the creator's “Musical Chairs” series.
The demo media is in `public/media` and is not included in the reusable library package. These assets retain their original licenses and are excluded from the repository's MIT license; see the [media license notice](https://intertext.dev/media/LICENSE.md) for the file-by-file mapping.
### Audio pills
Choose **Film music**, upload an audio file, or select Audio when adding a URL. The pill displays an amplitude waveform decoded from the actual clip, with black bars on white in dark mode and inverse colors in light mode. Use **Play audio** in Media settings to listen. **Play on hover**, directly below playback for audio, video, and GIFs, plays only while the pointer is over the pill. Selecting manual playback switches hover playback off. Played portions fill with the theme’s muted grey; pausing retains the current progress. Audio stays silent on load; hover playback is opt-in and subject to browser audio permissions. The sample is the original synth music from the Intertext demo film, without its UI sound effects, covered by MIT.
Use `kind="audio"` in HTML or React. Call the element’s `play()` method from a user gesture, or remove a previously set `paused` attribute to start playback; `pause()` or setting `paused` stops it. Customize `--artifact-audio-background`, `--artifact-audio-color`, and `--artifact-audio-progress` for an explicit theme; otherwise colors follow the system theme. Remote audio needs CORS permission for waveform decoding. A dashed baseline means the waveform is unavailable; playback may still work.