},
]
export function Example() {
return
}
```
## What it needs installed
This is one of the seven components that reach for something beyond React. Base UI supplies the tab semantics -- roving focus, the tab and panel relationship, arrow key movement -- and Motion drives the indicator. Both are optional peers, so they are only installed if you ask for them.
```tsx
npm install mischief-ui @base-ui/react motion
```
Import it from its own entry: mischief-ui/magnetic-tabs, not the package root.
The root import deliberately does not carry it, because a barrel holding it would fail for everyone who had not installed those two. That is the trade: a subpath import here, and no unexpected dependencies anywhere else.
## The magnetism, and doing without it
The indicator is spring-driven and leans towards the pointer as it moves across a tab, then settles when the pointer leaves. It is a stiff, light spring, so it arrives quickly rather than wobbling -- the effect should read as responsive, not bouncy.
When the reader has asked for reduced motion the lean is not applied at all and the indicator moves straight to the selected tab. Nothing about which tab is selected, or how it is reached from the keyboard, depends on any of this.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `items` | `MagneticTabItem[]` | Labels, values, panels, and disabled states. |
| `defaultValue` | `string` | The initially selected tab. |
| `value` | `string` | The selected value when controlled. |
| `onValueChange` | `(value: string) => void` | Runs when selection changes. |
| `className` | `string` | Classes for the root element. |
### MagneticTabItem
| Prop | Type | Description |
| --- | --- | --- |
| `value` | `string` | Identifies the tab. What value and onValueChange speak in. |
| `label` | `ReactNode` | The tab itself. |
| `content` | `ReactNode` | The panel shown while the tab is selected. |
| `disabled` | `boolean` | Listed but unselectable, and skipped by the arrow keys. |
## Accessibility
Base UI supplies tab semantics, arrow-key navigation, focus handling, and panel relationships. Pointer attraction is removed when reduced motion is enabled.
## Dependencies
- @base-ui/react
- motion
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Elastic Slider
A precise slider with a small amount of give at either end. The current value stays visible and the control works without a pointer.
- Family: Controls
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/elastic-slider
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/elastic-slider/elastic-slider.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/elastic-slider`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/elastic-slider`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/elastic-slider`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/elastic-slider`
Or as a package import:
```ts
import { ElasticSlider } from "mischief-ui/elastic-slider"
```
## Usage
```tsx
export function Volume() {
return (
)
}
```
## While dragging, and after
There are two callbacks because there are two moments, and confusing them is expensive. onValueChange fires continuously through a drag, which is what you want for a preview that has to keep up. onValueCommitted fires once, when the handle is released or a key is lifted.
Anything with a cost belongs in the committed callback: a request, a write, an undo entry. Putting a save in onValueChange sends one for every frame of a single drag.
```tsx
save({ volume: value })}
/>
```
## Reading the value
The number shown beside the label comes from formatValue, and so does the value announced to a screen reader. Use it to give the number its unit, because a bare 68 says nothing about what it measures.
```tsx
formatValue={(value) => `${value}%`}
formatValue={(value) => `${(value / 100).toFixed(2)} s`}
```
min, max, and step are passed to the underlying Base UI slider, so a step of 5 constrains the keyboard as well as the drag. Motion is used only for the stretch: with reduced motion the handle still tracks exactly, it simply stops deforming.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `label` | `ReactNode` | The visible and accessible label. |
| `defaultValue` | `number` | The initial uncontrolled value. Defaults to 50. |
| `value` | `number` | The current value when controlled. |
| `onValueChange` | `(value: number) => void` | Runs while the value changes. |
| `onValueCommitted` | `(value: number) => void` | Runs when interaction finishes. |
| `min, max, step` | `number` | Range and increment settings. |
| `formatValue` | `(value: number) => string` | Formats the visible value. |
## Accessibility
The control uses Base UI slider behavior and a native output for the visible value. It supports pointer, touch, and keyboard input. End feedback is removed when reduced motion is enabled.
## Dependencies
- @base-ui/react
- motion
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Hold Button
A confirmation button for actions that deserve a second thought. Release early to cancel, or activate once with a keyboard.
- Family: Controls
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/hold-button
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/hold-button/hold-button.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/hold-button`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/hold-button`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/hold-button`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/hold-button`
Or as a package import:
```ts
import { HoldButton } from "mischief-ui/hold-button"
```
## Usage
```tsx
export function RemoveDownload() {
return (
Hold to remove download
)
}
```
## Why hold instead of confirm
A confirmation dialog asks a question the answer to which is almost always yes, and people learn to dismiss it without reading. A hold cannot be dismissed by reflex: it takes a second of deliberate, continuous pressure, and letting go early cancels it.
That makes it a good fit for the destructive action that is common enough to be annoying behind a dialog but severe enough that an accident matters -- deleting a draft, clearing a queue, revoking a key. It is a poor fit for anything irreversible and rare, where a dialog that names what is about to happen is still the right answer.
## How long the hold is
The default is 900ms, which is long enough to feel like a decision and short enough not to feel broken. Shorter values are accepted but floored at 500ms, because below that the hold stops being deliberate and becomes a slow click -- exactly the reflex it exists to interrupt.
onComplete runs once, at the end of a full hold. Releasing early, dragging off the button, or pressing Escape all cancel it, and nothing is reported.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `onComplete` | `() => void` | Runs once after a completed hold or keyboard activation. |
| `duration` | `number` | Hold time in milliseconds. Defaults to 900, minimum 500. |
| `completeLabel` | `ReactNode` | Content shown after completion. |
| `children` | `ReactNode` | The idle button content. |
| `...buttonProps` | `ButtonHTMLAttributes` | Native button attributes except pointer and click handlers. |
## Accessibility
Pointer users hold to confirm. Keyboard and assistive technology users activate the native button once, avoiding a timing barrier. Progress and completion are announced politely.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Shift Button
A call to action that trades its leading icon for a directional cue when someone approaches it.
- Family: Controls
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/shift-button
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/shift-button/shift-button.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/shift-button`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/shift-button`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/shift-button`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/shift-button`
Or as a package import:
```ts
import { ShiftButton } from "mischief-ui/shift-button"
```
## Usage
```tsx
export function DownloadButton() {
return (
}
leadingIcon={}
>
Download for Mac
)
}
```
## The shift
On hover the leading icon slides out to the left and fades, while the trailing icon slides in from the right to take its place. The grid keeps a fixed column for each, so the label never moves and the button never changes width -- the motion happens inside a stable shape.
Under reduced motion the trailing icon is not shown at all and the leading icon stays exactly where it is. The button is then simply a button with an icon, which is the point: the shift is decoration, and nothing is communicated by it alone.
Give trailingIcon only when it says something -- an arrow for navigation, a check for a completed action. Leaving it out is fine, and the leading icon then stays put for everyone.
## What it needs installed
Base UI supplies the button, which is why this component is imported from its own entry rather than the package root, and why @base-ui/react has to be installed alongside.
```tsx
npm install mischief-ui @base-ui/react
```
## API
| Prop | Type | Description |
| --- | --- | --- |
| `children` | `ReactNode` | The button or link label. |
| `leadingIcon` | `ReactNode` | The icon visible at rest. |
| `trailingIcon` | `ReactNode` | The arriving icon. Defaults to an arrow. |
| `render` | `ReactElement` | Renders another element, such as a link. |
| `className` | `string` | Classes for the root element. |
## Accessibility
Base UI preserves native button behavior and supports rendering a real link for navigation. The label never disappears, focus remains visible, and reduced motion keeps both the leading icon and text still.
## Dependencies
- @base-ui/react
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Impossible Checkbox
A checkbox with one stubborn rule: the bear will not let you leave it on. Best kept for demos, Easter eggs, and harmless preferences.
- Family: Controls
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/impossible-checkbox
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/impossible-checkbox/impossible-checkbox.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/impossible-checkbox`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/impossible-checkbox`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/impossible-checkbox`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/impossible-checkbox`
Or as a package import:
```ts
import { ImpossibleCheckbox } from "mischief-ui/impossible-checkbox"
```
## Usage
```tsx
export function Demo() {
return (
console.log({ attempt })}
/>
)
}
```
## Do not use this for anything that matters
This is a joke. A paw reaches out and unchecks the box, and it keeps doing it until you have tried enough times. It is genuinely funny once, and it is genuinely infuriating if it stands between someone and something they need.
So: never for consent, terms, permissions, a privacy choice, or anything a form submits. Anything a person must be able to set, they must be able to set on the first try. A 404 page, an easter egg, a demo, a settings toggle for something that does not exist -- those are where it belongs.
revealAfter and angryAfter decide how long the bit runs before it gives up and lets the box stay checked. Keep them low if there is any chance someone actually wanted the checkbox.
## Following along
onAttempt fires with a running count each time someone tries, which is what you would build the rest of the joke around -- a line of copy that escalates, a sound, a message that gives in before the paw does.
```tsx
setTaunt(taunts[attempt] ?? taunts.at(-1))}
/>
```
Motion is an optional peer and drives the whole performance. With reduced motion the animation collapses to nothing, so consider whether the joke still lands for that reader, and offer them the plain checkbox instead.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `onAttempt` | `(attempt: number) => void` | Runs each time someone tries to check it. |
| `revealAfter` | `number` | Attempts before the bear starts peeking. Defaults to 2. |
| `angryAfter` | `number` | Attempts before the bear looks angry. Defaults to 5. |
| `className` | `string` | Classes and custom properties for the frame. |
| `...inputProps` | `InputHTMLAttributes` | Native checkbox attributes except checked and onChange. |
## Accessibility
The control is a native checkbox and works with pointer, touch, and keyboard input. A polite live region explains that the bear switched it off. Reduced motion skips the swat sequence while keeping the result clear. Do not use it for consent, safety, or any setting a person genuinely needs to change.
## Dependencies
- motion
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Floating Index
A compact outline for long pages. It keeps the active section and reading progress visible without becoming another permanent sidebar.
- Family: Wayfinding
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/floating-index
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/floating-index/floating-index.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/floating-index`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/floating-index`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/floating-index`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/floating-index`
Or as a package import:
```ts
import { FloatingIndex } from "mischief-ui/floating-index"
```
## Usage
```tsx
const items = [
{ id: "introduction", label: "Introduction" },
{ id: "details", label: "Details" },
{ id: "examples", label: "Examples" },
]
export function PageIndex() {
return
}
```
## Watching something other than the window
By default the index tracks the page. When your sections scroll inside an element -- a panel, a modal, a split view -- pass that element and it observes the right scroller instead of quietly tracking a page that never moves.
```tsx
const panel = useRef(null)
{sections.map((section) => (
…
))}
```
Pass container instead when you already hold the element rather than a ref.
Every item's id must match the id of a real element, because that is what is being observed. An item pointing at nothing is simply never marked active.
## Where it sits
It floats at the top of the viewport, centred, which suits a page whose header scrolls away. Anywhere else is the position prop rather than a set of utilities cancelling the default one at a time.
```tsx
```
The corners are top, bottom, and the four of them named.
Bottom right is where a back-to-top control usually lives, so check they are not stacked on each other before choosing it. className still wins for anything the prop does not cover, such as the width.
## The ring
The ring around the index fills with how far through the scroller the reader is, which gives the sense of remaining length that a list of section names alone does not. It is decoration -- the active item is what carries the position, and it is marked as current for a screen reader.
Motion is an optional peer here, so the component is imported from its own entry. With reduced motion the ring stops animating between values and simply reflects the current one.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `items` | `FloatingIndexItem[]` | Section ids, labels, and optional icons. |
| `label` | `string` | The toggle label. Defaults to Index. |
| `showActiveLabel` | `boolean` | Once past the top, the toggle says which section the reader is in rather than repeating the label. Defaults to true. |
| `position` | `"top" | "bottom" | "top-left" | "top-right" | "bottom-left" | "bottom-right"` | Which corner it floats in. Defaults to "top", centred. |
| `activeId` | `string` | The active section when controlled. |
| `defaultActiveId` | `string` | The initial active section. |
| `onActiveChange` | `(id: string) => void` | Runs when the visible section changes. |
| `container` | `HTMLElement | null` | Tracks a controlled scroll container that can change after mount. |
| `containerRef` | `RefObject` | Tracks a scroll container instead of the page. |
| `className` | `string` | Classes for placement and appearance. |
### FloatingIndexItem
| Prop | Type | Description |
| --- | --- | --- |
| `id` | `string` | Must match the id of the element it points at. |
| `label` | `string` | The section name. |
| `icon` | `ReactNode` | Shown in place of the marker. |
## Accessibility
The index is a labelled navigation landmark with native buttons, visible focus, aria-expanded on the toggle, and aria-current on the active section. Escape closes the outline. Reduced motion removes panel animation and jumps rather than scrolling smoothly. The navigation landmark keeps the name it was given even when the toggle is showing the current section instead, so it is still found by that name.
## Dependencies
- motion
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Command Palette
A search dialog over anything you can list, opened from a keyboard shortcut, with ranked matches and hidden keywords.
- Family: Wayfinding
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/command-palette
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/command-palette/command-palette.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/command-palette`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/command-palette`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/command-palette`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/command-palette`
Or as a package import:
```ts
import { CommandPalette } from "mischief-ui/command-palette"
```
## Usage
```tsx
const items = [
{ id: "hold-button", label: "Hold Button", group: "Controls" },
{ id: "redaction", label: "Redaction", group: "Documents" },
]
export function Search() {
return open(item.id)} />
}
```
## How matches are ranked
Everything is matched case-insensitively against the trimmed query, and each item is scored by the strongest thing it matched. Lower wins, and ties are broken alphabetically by label, so the order never depends on the order you passed items in.
| Rank | Match |
| --- | --- |
| 0 | The label is exactly the query |
| 1 | The label starts with the query |
| 2 | The label contains the query |
| 3 | The group contains the query |
| 4 | A keyword contains the query |
| 5 | The description contains the query |
An item matching none of these is dropped rather than ranked last. Use keywords for the words people actually type that are not in the label -- the old name for a thing, a synonym, the noun rather than the verb.
```tsx
const items = [
{
id: "redaction",
label: "Redaction",
group: "Documents",
description: "Mark regions to black out",
keywords: ["privacy", "black bar", "hide", "gdpr"],
},
]
```
Typing privacy finds this even though the label never says it.
## Ranking it yourself
The built-in tiers suit labels and keywords. When they do not -- fuzzy matching, a field the component knows nothing about, a weighting that puts recent things first -- pass rank and score the items yourself. Lower is a better match, and false drops one.
```tsx
import { rankCommandItem } from "mischief-ui/command-palette"
item.pinned ? -1 : rankCommandItem(item, query)
}
/>
```
The built-in ranker is exported, so yours can defer to it rather than reproduce it.
The query arrives as it was typed rather than lowercased, so a ranker of your own can be case-sensitive. Turn filtering off entirely with filter={false} when the ordering is already someone else's decision.
## Results from a server
The palette filters and ranks whatever array it is given, which is right when the whole set is already in the browser. Once results come from a search endpoint, two things change: you need to know what was typed, and the palette must stop re-ranking what the server already ordered.
```tsx
const [query, setQuery] = useState("")
const [hits, setHits] = useState([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!query) return setHits([])
const controller = new AbortController()
setLoading(true)
search(query, { signal: controller.signal })
.then(setHits)
.finally(() => setLoading(false))
return () => controller.abort()
}, [query])
```
Debouncing and aborting stay yours: only you know what the endpoint costs.
While loading, the palette says it is searching rather than reporting that nothing matched, because an empty list mid-flight is not an answer. The listbox is marked busy at the same time, so a screen reader is told to wait instead of hearing an empty set.
## One palette per chord
The shortcut is bound to the window, so every palette on the page hears it. Two of them on the same chord used to open two stacked dialogs from a single keypress, which is how this page found the bug: the site's own search already owns Mod+K.
A palette now ignores a keypress something else has already claimed, so the first listener wins and nobody gets a stack of modals. That is a guard against a mistake rather than a licence to make it, because which palette wins depends on mount order. Give the second one its own chord.
```tsx
```
Mod+K, Mod+J, and one opened from your own code.
The same holds for a chord your page handles itself: if your listener calls preventDefault, the palette leaves that keypress alone.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `items` | `CommandItem[]` | Id and label, plus an optional group, description, and keywords that match without being shown. |
| `onSelect` | `(item: CommandItem) => void` | Runs with the chosen item. Navigate or act from here. |
| `open, defaultOpen, onOpenChange` | `boolean` | Whether the dialog is showing, controlled or uncontrolled. |
| `shortcut` | `string | false` | Key used with Meta or Control. Defaults to "k". Pass false to bind nothing. |
| `maxResults` | `number` | How many matches to show. Defaults to 8. |
| `onQueryChange` | `(query: string) => void` | Called as the query changes, for fetching results yourself. |
| `loading` | `boolean` | Says results are on their way. Pair it with onQueryChange. |
| `loadingMessage` | `ReactNode` | Shown while waiting. Defaults to "Searching…". |
| `filter` | `boolean` | Rank and filter here. Turn off when the server already did. |
| `rank` | `(item, query) => number | false` | Score items yourself. Lower is better; false drops one. |
| `placeholder, label, emptyMessage` | `string, string, (query) => ReactNode` | Copy for the field, the dialog, and the no-match state. |
### CommandItem
| Prop | Type | Description |
| --- | --- | --- |
| `id` | `string` | Unique within the set. |
| `label` | `string` | What is shown and matched first. |
| `description` | `string` | A second line, matched last. |
| `group` | `string` | Heading the item is listed under, and matched. |
| `keywords` | `string[]` | Words that should find the item but are not shown. |
## Accessibility
The field is a combobox owning a listbox, and the highlighted option is reported through aria-activedescendant, so arrow keys move the selection while focus stays in the field and typing is never interrupted. It is a native dialog opened as a modal, which brings the focus trap, the escape key, and inert content behind it without rebuilding any of them. A search that matches nothing says so in a status region rather than showing an empty list.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Scroll to Top Button
A floating way back after someone has moved down a long page or scroll area. It stays hidden near the top.
- Family: Wayfinding
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/scroll-to-top-button
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/scroll-to-top-button/scroll-to-top-button.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/scroll-to-top-button`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/scroll-to-top-button`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/scroll-to-top-button`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/scroll-to-top-button`
Or as a package import:
```ts
import { ScrollToTopButton } from "mischief-ui/scroll-to-top-button"
```
## Usage
```tsx
export function LongPage() {
return (
<>
{/* Long page content */}
>
)
}
```
## When the page has its own scroller
Smooth-scroll libraries such as Lenis take the page's scrolling away from the browser, and a native scrollTo either fights them or does nothing. Claim the click and do it yourself: onClick runs first, and calling preventDefault stops the built-in scroll.
```tsx
{
event.preventDefault()
lenis.scrollTo(0, { immediate: prefersReducedMotion })
}}
/>
```
The same hook works for a virtualised list, or any scroller you own.
## When it appears
The button stays out of the way until the reader is showAfter pixels down, so a short page never grows a control for a journey nobody took. It fades in and out rather than appearing, and while hidden it is completely inert: not clickable, not focusable, and not announced.
Like the floating index, it watches the window unless you hand it a container, which is what you want when the thing that scrolls is a panel rather than the page.
```tsx
```
## Smooth, and when not to be
behavior is passed straight to the browser, so "smooth" animates and "auto" jumps. A long page smooth-scrolled from the bottom can take an unpleasantly long time to arrive; if your pages are long, "auto" is the kinder default.
Browsers already honour a reduced-motion preference for smooth scrolling, so you do not need to switch the value yourself for that reason.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `container` | `HTMLElement | null` | Scrolls a controlled container that can change after mount. |
| `containerRef` | `RefObject` | Scrolls a container instead of the page. |
| `showAfter` | `number` | Scroll distance before the button appears. Defaults to 320. |
| `behavior` | `"auto" | "instant" | "smooth"` | The requested scroll behavior. Defaults to smooth. |
| `icon` | `ReactNode` | Replaces the default arrow. Keeps the hover lift. |
| `label` | `string` | The accessible name and title. |
| `className` | `string` | Classes for placement and appearance. |
| `...buttonProps` | `ButtonHTMLAttributes` | Native button attributes. |
## Accessibility
The control is a named native button with a 48px target. While there is nothing to scroll back from it is hidden from assistive technology and taken out of the tab order, so it is never a stop on the way through the page. Scrolling is immediate when reduced motion is requested, and the fade stops with it.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Save Bar
A bar that exists only while a form has unsaved changes. It says so, saves, confirms, and leaves.
- Family: Controls
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/save-bar
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/save-bar/save-bar.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/save-bar`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/save-bar`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/save-bar`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/save-bar`
Or as a package import:
```ts
import { SaveBar } from "mischief-ui/save-bar"
```
## Usage
```tsx
export function Settings() {
const form = useSettingsForm()
return (
<>
form.submit()}
onReset={() => form.reset()}
/>
>
)
}
```
## What it asks of the form
Two things. dirty says whether there is anything to save, and onSave does it. The bar keeps no copy of your values, holds no opinion about validation, and has no state you have to keep in step with your own.
```tsx
form.reset()}
/>
```
Any form library works, because the only thing being read is a boolean.
onSave may return a promise. While it is pending the bar says it is saving and both buttons go unavailable. When it resolves the check is drawn, and the bar drops away once dirty turns false. A form that is still dirty after a save keeps its bar, which is the right outcome when the write did not take.
## When the save does not land
A rejection is the failed state. The bar holds its place, the message changes, and Save comes back as Try again, so the changes are still on screen and still recoverable. onSaveError hands you the rejection itself.
```tsx
report(error)}
/>
```
The failure is kept only while there is still something to retry. If the form goes clean, by a reset or by anything else, the message leaves with the bar rather than outliving what it was about.
## The shortcut and the reload
Cmd+S and Ctrl+S save while there is something to save, and do nothing while there is not, so a page with no unsaved work leaves the key to the browser. Escape is deliberately not bound: it already belongs to whatever dialog or menu is open over the form.
warnOnLeave is on by default. A bar that reports unsaved changes and then lets the tab close without a word is not telling the truth. Turn it off where the changes survive a reload on their own, or inside a preview like the one above.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `dirty` | `boolean` | Whether the form holds unsaved changes. The bar appears for this and nothing else. |
| `onSave` | `() => void | Promise` | Runs the save. A rejection is the failed state. |
| `onReset` | `() => void` | Discards the changes. Omit it and no Reset button is drawn. |
| `onSaveError` | `(error: unknown) => void` | Receives the rejection, for logging or a more specific message. |
| `message, savingMessage, savedMessage, errorMessage` | `string` | The line beside the indicator in each state. |
| `saveLabel, resetLabel, retryLabel` | `string` | Button copy. Save becomes the retry label after a failure. |
| `shortcut` | `boolean` | Saves on Cmd+S and Ctrl+S. Defaults to true. |
| `warnOnLeave` | `boolean` | Confirms a reload or a close while dirty. Defaults to true. |
| `label` | `string` | The accessible name of the bar. |
| `data-state (SaveBarState)` | `"clean" | "dirty" | "saving" | "saved" | "error"` | The step the bar is on, written to the root for styling. |
| `className` | `string` | Classes for placement and appearance. |
| `...divProps` | `HTMLAttributes` | Native div attributes. |
## Accessibility
The bar is a named region. While the form is clean it is hidden from assistive technology and its buttons are out of the tab order, so a page with nothing to save carries no extra stop. The message is repeated in a live region kept outside the bar, because a region that was hidden a moment ago is not reliably read when it returns, and that copy is what announces the save and the failure. Reduced motion removes the rise, the spin, and the drawn check, leaving a bar that is simply there and then gone.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Install Command
The install line for a library, switchable between package managers, with the runner and the installer kept apart.
- Family: Docs
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/install-command
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/install-command/install-command.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/install-command`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/install-command`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/install-command`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/install-command`
Or as a package import:
```ts
import { InstallCommand } from "mischief-ui/install-command"
```
## Usage
```tsx
export function Install() {
return (
)
}
```
## Running something, or adding it
The two props are two different verbs, and a block can offer either or both. run is a one-off execution -- a generator, a registry command -- and add is a dependency going into package.json. Each manager has its own word for each, and picking a manager applies to whichever verb is showing.
| Manager | run | add |
| --- | --- | --- |
| npm | npx | npm install |
| pnpm | pnpm dlx | pnpm add |
| yarn | yarn dlx | yarn add |
| bun | bunx --bun | bun add |
Pass the arguments without the verb -- shadcn@latest add tabs, not npx shadcn@latest add tabs -- and the block builds the whole line. Choosing pnpm and then switching to the package option gives pnpm add rather than snapping back to the default.
## When it is not an install
The package managers are the common case, not the only one. A block that offers a skill, a server config, and a curl call is the same thing -- a few labelled snippets and one copy button -- but none of them is npm install. Pass tabs and they replace the managers entirely.
```tsx
```
wrap suits anything that is not one line, such as a JSON block.
## The agent option
prompt adds a third choice that is not a command at all: an instruction to paste into a coding agent. It sits beside the shell commands because that is now one of the ways people install things, and copying it uses the same control.
```tsx
```
Only the options you supply are offered, so a block with just add shows no manager row at all and no empty tabs.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `tabs` | `InstallTab[]` | Your own tabs, which replace the package managers entirely. |
| `defaultTab` | `string` | Which of those opens first. Defaults to the first. |
| `run` | `string` | Arguments for a one-off runner, such as shadcn@latest add tabs. |
| `add` | `string` | Packages to add as a dependency. |
| `prompt` | `string` | An instruction to paste into a coding agent, offered beside the commands. |
| `managers, defaultManager` | `PackageManager[], PackageManager` | Which package managers to offer and which leads. Defaults to npm. |
| `packageLabel, promptLabel, note` | `string, string, ReactNode` | Copy for the two extra options and the line beneath. |
### InstallTab
| Prop | Type | Description |
| --- | --- | --- |
| `id` | `string` | Unique within the set. What defaultTab names. |
| `label` | `string` | The tab as shown. |
| `value` | `string` | What is displayed and copied. |
| `wrap` | `boolean` | Wrap rather than scroll, for more than one line. |
## Accessibility
The options are a labelled group of toggle buttons reporting their pressed state, so the current choice is announced rather than shown only by a border. Running a package and adding a dependency are separate verbs, so they come from separate tables instead of one being derived from the other by rewriting a string. Only the options you supply are rendered, and a prompt wraps rather than scrolling sideways.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Copy for AI
Hands the page to an assistant as markdown, by clipboard, by link, or by opening it somewhere that can read it.
- Family: Docs
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/copy-for-ai
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/copy-for-ai/copy-for-ai.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/copy-for-ai`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/copy-for-ai`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/copy-for-ai`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/copy-for-ai`
Or as a package import:
```ts
import { CopyForAi } from "mischief-ui/copy-for-ai"
```
## Usage
```tsx
export function PageActions({ markdown }: { markdown: string }) {
return (
)
}
```
## Copying the page, not the address
The main control copies the markdown itself rather than a link to it. That is the difference between an agent having the page and an agent being told where the page is -- one of which works when the model cannot browse, is behind a login, or is reading a build that has not shipped yet.
Generate that markdown from the same source your page renders from. Two hand-written copies of the same documentation disagree within a week.
## Sending it somewhere
The menu's destinations each turn a prompt into a URL for a particular assistant. They are ordinary links, opened only when someone chooses one, and you can replace the set entirely to add your own or to remove any you would rather not point at.
```tsx
entry.id !== "grok"),
{ id: "internal", name: "Our assistant", href: (prompt) =>
`https://ai.example.com/new?q=${encodeURIComponent(prompt)}` },
]}
/>
```
Whatever the prompt contains ends up in a URL to a third party, and URLs are logged, kept in history, and sent as referrers. Never build one out of a customer's data, an internal document, or anything you would not paste into a public chat.
The view-as-markdown entry is dropped when there is no markdownUrl, so the menu never offers a link to nothing.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `markdown` | `string` | The page as markdown. This is what the button copies. |
| `markdownUrl` | `string` | Where the same markdown is served. Adds a link and points destinations at it. |
| `prompt` | `string` | What a destination is asked to do. Defaults to reading the markdown address. |
| `destinations` | `AiDestination[]` | Where the page can be opened. Defaults to ChatGPT and Claude. |
| `copyLabel, copiedLabel, viewLabel, menuLabel` | `string` | Copy for the button and the menu. |
### AiDestination
| Prop | Type | Description |
| --- | --- | --- |
| `id` | `string` | Unique within the set. |
| `name` | `string` | Shown as "Open in {name}". |
| `href` | `(prompt: string) => string` | Builds the URL. Encode the prompt yourself. |
| `icon` | `ReactNode` | Shown beside the name. |
## Accessibility
Copying is a button and every destination is a link, so each behaves the way its shape promises. The menu closes on Escape and on a click outside it, and the copy is announced through a polite live region rather than only changing an icon. Destinations open in a new tab and are marked so they are not followed.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Table of Contents
An outline of the page that keeps up with the reader, marking the section they are in as they scroll.
- Family: Docs
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/table-of-contents
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/table-of-contents/table-of-contents.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/table-of-contents`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/table-of-contents`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/table-of-contents`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/table-of-contents`
Or as a package import:
```ts
import { TableOfContents } from "mischief-ui/table-of-contents"
```
## Usage
```tsx
const sections = [
{ id: "install", label: "Install" },
{ id: "usage", label: "Usage" },
]
export function Outline() {
return
}
```
## How the current section is chosen
On every scroll the component reads where each heading is and marks the last one to have passed a line near the top of the viewport. That line is offset, which defaults to 96 pixels -- set it to roughly the height of whatever sits fixed above your content, or headings will highlight while still hidden behind it.
This is deliberately position tracking rather than an intersection observer. An observer only reports as a heading crosses an edge, so a heading scrolled past between two callbacks leaves the wrong entry marked, and the index reads a section behind the page. Reading positions costs a little more and is never wrong.
```tsx
```
Every id must belong to a real element; one that does not is simply never marked.
## If you use smooth scrolling
With scroll-behavior set to smooth, a click on an entry animates to the heading, and the marked section changes several times on the way as each heading passes the line. That is correct, and it is also why measuring the active entry immediately after a click tells you where the page was, not where it is going.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `sections` | `TocSection[]` | The id of each section and the label to show for it. |
| `offset` | `number` | How far below the top a heading counts as reached. Defaults to 96. |
| `label` | `string` | The accessible name and the visible heading. |
| `onActiveChange` | `(id: string | null) => void` | Runs when the reader moves into another section. |
### TocSection
| Prop | Type | Description |
| --- | --- | --- |
| `id` | `string` | The id of the element this entry points at. |
| `label` | `string` | How the section is named in the index. |
## Accessibility
The current entry is marked with aria-current, so its position is announced rather than shown only in weight. Sections on a documentation page are tall and very uneven, so this tracks the heading most recently scrolled past instead of observing which box intersects a band, which selects several at once or none. The final section is often too short to reach the line, so the bottom of the page selects it outright.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# File Upload
A file picker and dropzone with clear validation and a visible queue. Connect your upload function when you need progress, cancel, and retry.
- Family: Files
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/file-upload
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/file-upload/file-upload.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/file-upload`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/file-upload`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/file-upload`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/file-upload`
Or as a package import:
```ts
import { FileUpload } from "mischief-ui/file-upload"
```
## Usage
```tsx
async function uploadFile(file, { signal, onProgress }) {
return uploadToYourStorage(file, { signal, onProgress })
}
export function Attachments() {
return (
)
}
```
## Validation is not a boundary
accept and maxSize exist so someone can correct a mistake before waiting for an upload to fail. They are not security. Every one of them is trivially bypassed -- the accept attribute is a filter in a file dialog, the size is read from the file the browser hands over, and the type comes from an extension rather than the bytes.
Repeat every check on the server, and sniff the actual content rather than trusting the reported type. A file named invoice.pdf is only a PDF if its bytes say so.
## Why a file was refused
Refused files arrive through onReject with a code, so you can respond to the reason rather than parsing a message.
| Code | Meaning |
| --- | --- |
| type | Did not match accept. |
| size | Larger than maxSize. |
| duplicate | Already in the queue. |
| count | Would exceed maxFiles. |
Each rejection carries the file it refers to, so several can be reported at once when a whole folder is dropped in.
## Driving progress
The component queues files and shows their state; it never uploads anything. Move each item through its status yourself, and set progress from whatever your transport reports.
```tsx
async function upload(item) {
update(item.id, { status: "uploading", progress: 0 })
try {
const result = await put(item.file, {
onProgress: (progress) => update(item.id, { progress }),
})
update(item.id, { status: "complete", progress: 100, result })
} catch (error) {
update(item.id, { status: "error", error: String(error) })
}
}
```
## API
| Prop | Type | Description |
| --- | --- | --- |
| `accept` | `string` | MIME types and extensions accepted by the picker. |
| `multiple` | `boolean` | Allows more than one file. Defaults to true. |
| `maxFiles` | `number` | Maximum files in the queue. Defaults to 5. |
| `maxSize` | `number` | Maximum bytes per file. Defaults to 10 MB. |
| `uploadFile` | `FileUploadAdapter` | Your async upload function with progress and cancellation hooks. |
| `autoUpload` | `boolean` | Starts the adapter when files are accepted. Defaults to true. |
| `onFilesAccepted` | `(files: File[]) => void` | Runs with files that pass validation. |
| `onFilesRejected` | `(rejections) => void` | Reports type, size, count, and duplicate failures. |
| `onFilesChange` | `(entries) => void` | Runs when the queue or an upload state changes. |
| `value, defaultValue` | `FileUploadEntry[]` | Controls the queue or supplies its initial entries. |
| `onValueChange` | `(entries) => void` | Updates a controlled queue. |
| `onUploadComplete` | `(entry, result) => void` | Receives the value returned by your upload adapter. |
| `disabled` | `boolean` | Disables both picking and dropping. |
| `className` | `string` | Classes for the root element. |
## Accessibility
The picker is a named native button backed by a file input. Drag and drop is an additional path, not the only one. Validation and upload changes are announced politely. Every queue action and the primary picker keep a 44px target. Progress uses native progressbar semantics. File type and size checks must also run on the server because browser validation is not a security boundary.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# File Thumbnail
A compact image preview for attachments, upload queues, and file lists. Browser image files work without any setup.
- Family: Files
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/file-thumbnail
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/file-thumbnail/file-thumbnail.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/file-thumbnail`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/file-thumbnail`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/file-thumbnail`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/file-thumbnail`
Or as a package import:
```ts
import { FileThumbnail } from "mischief-ui/file-thumbnail"
```
## Usage
```tsx
export function ImagePreview({ file }: { file: File }) {
return (
)
}
```
## How it decides what a file is
The badge is the extension taken from the name, upper-cased and cut to five characters. A file is treated as an image when its MIME type starts with image/, or when the extension is one of png, jpg, jpeg, gif, webp, svg, or avif.
Both of those are guesses from a name, which is fine for choosing an icon and useless as a check. Nothing here validates anything: a script renamed to .png is still shown as an image.
## Previews are yours to make
No preview is generated. Pass previewImageUrl and it is shown; leave it out and the file gets its extension badge instead. That keeps the component free of any renderer, and lets the picture come from wherever it actually lives -- a stored thumbnail, a signed URL, an object URL you made in the browser.
```tsx
const url = useMemo(() => URL.createObjectURL(file), [file])
useEffect(() => () => URL.revokeObjectURL(url), [url])
```
Revoke an object URL when you are done with it, or the file stays in memory.
isLoading covers the wait while a thumbnail is being made, and hasError covers one that could not be. Passing null for previewImageUrl is the honest way to say there will not be one.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `file` | `File | FileThumbnailFile` | A browser File or an object with a name and optional MIME type. |
| `previewImageUrl` | `string | null` | An existing image URL. Browser image File objects preview themselves when omitted. |
| `previewAspectRatio` | `number` | The frame aspect ratio. Defaults to 1. |
| `fit` | `"cover" | "contain"` | Image fitting. Defaults to cover. |
| `alt` | `string` | Alternative text for the preview image. Defaults to decorative. |
| `isLoading` | `boolean` | Shows the loading state. |
| `hasError` | `boolean` | Forces the file-type fallback. |
| `previewClassName` | `string` | Classes for the preview content. |
| `className` | `string` | Classes for the preview frame. |
### FileThumbnailFile
| Prop | Type | Description |
| --- | --- | --- |
| `name` | `string` | Filename. The extension becomes the badge. |
| `type` | `string` | MIME type, used to spot an image. Optional. |
## Accessibility
Failed previews expose the file name and explain that the image is unavailable. Loading previews use a named status. Preview images default to decorative because file names usually sit beside thumbnails, but alt text can be supplied when the image itself carries meaning. Reduced motion removes the fade and shimmer movement.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Conversation
The scroll container a thread lives in. It follows a streaming reply to the bottom, and stops the moment the reader scrolls up to read something older.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/conversation
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/conversation/conversation.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/conversation`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/conversation`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/conversation`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/conversation`
Or as a package import:
```ts
import { Conversation } from "mischief-ui/conversation"
```
## Usage
```tsx
export function Thread({ messages }: { messages: Msg[] }) {
return (
{messages.map((message) => (
{message.content}
))}
)
}
```
## Following the newest message
The viewport sticks to the bottom while it is already there, so a streaming answer stays in view. Scroll up and following stops immediately; come back within threshold pixels of the end and it resumes. That is what makes it possible to read back through a conversation while one is still arriving, without being dragged away mid-sentence.
A jump control appears whenever following has stopped, so getting back to the newest message is one click rather than a long scroll. onFollowChange reports the same state if you want to show something of your own.
```tsx
{messages.map((message) => (
{message.text}
))}
```
Raise threshold when messages are tall, so near the bottom still counts as the bottom.
Turn the behaviour off entirely with stickToBottom={false} for a transcript that should open where it was left rather than at the end.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `stickToBottom` | `boolean` | Follows new content to the bottom. Defaults to true. |
| `threshold` | `number` | How close to the bottom still counts as following, in pixels. Defaults to 48. |
| `showJumpButton, jumpLabel` | `boolean, string` | The control offered once following has stopped. |
| `onFollowChange` | `(following: boolean) => void` | Runs when the reader leaves or returns to the bottom. |
## Accessibility
Scrolling is never taken away from the reader. New content is followed only while they are already at the bottom, so scrolling up to read something older is not undone by the next token. Returning is an ordinary button rather than a gesture. The viewport uses contained overscroll so reaching the end does not scroll the page behind it.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Message
One turn in a thread, with a role, an optional avatar and timestamp, and actions that stay reachable without a pointer.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/message
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/message/message.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/message`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/message`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/message`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/message`
Or as a package import:
```ts
import { Message } from "mischief-ui/message"
```
## Usage
```tsx
export function Turn() {
return (
)
}
```
## Avatars
The avatar slot takes whatever you give it and crops it into a 28 pixel circle. An image is scaled to fill and centred, so a portrait or a wide crop both work without letterboxing; initials or an icon work equally well, and are what to fall back to when someone has no picture.
```tsx
}>
{text}
{answer}
```
Leave the image alt empty: the name beside it already says who this is.
The whole slot is hidden from assistive technology, because a picture of someone next to their name adds nothing to hear. That is also why an avatar alone is not enough to identify a speaker -- always pass name as well, or accept the role's default wording.
## Roles and waiting
role sets the alignment, the tone, and the default name -- You, Assistant, or System. Override that with name whenever you have something better, which for an assistant is usually the product's own name rather than the word assistant.
pending marks a message that has been sent but not yet answered, or one still being written. Use it for the turn that is waiting rather than for one that failed: a message that will never arrive should say so in its own content, not sit pending forever.
actions is the row beneath the message, and is where Response Actions is designed to go.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `role` | `"user" | "assistant" | "system"` | Who is speaking. Sets the layout and the announced name. |
| `name` | `ReactNode` | Overrides the name read out for the role. |
| `avatar` | `ReactNode` | Initials, an icon, or an img. An image is cropped to fill the circle whatever its shape. |
| `timestamp` | `ReactNode` | Shown under the body. |
| `actions` | `ReactNode` | Controls such as copy or regenerate. |
| `pending` | `boolean` | Marks the turn busy while it is still arriving. |
## Accessibility
Each turn is an article naming its speaker, so a thread can be navigated turn by turn instead of read as one block. The avatar is hidden from assistive technology, since the speaker is already named in text, so a profile picture needs no alternative text of its own. Actions are hidden with opacity rather than display, which keeps them focusable by keyboard and reveals them on focus as well as hover; on touch, where there is no hover, they stay visible. A turn still arriving reports aria-busy.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Prompt Input
The composer. Grows with the message, sends on Enter, keeps Shift+Enter for a new line, and turns into a stop button while a reply streams.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/prompt-input
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/prompt-input/prompt-input.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/prompt-input`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/prompt-input`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/prompt-input`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/prompt-input`
Or as a package import:
```ts
import { PromptInput } from "mischief-ui/prompt-input"
```
## Usage
```tsx
export function Composer() {
return (
)
}
```
## Sending and not sending
Enter submits and Shift+Enter starts a new line, which is what people expect from a message box and the opposite of what a plain textarea does. Submission is skipped when the field is empty or holds only whitespace, so a stray Enter never sends an empty turn.
The box grows with what is typed and stops at maxRows, scrolling after that rather than pushing the rest of the page away.
## Submitting, then stopping
status decides which control is offered. While an answer is being generated the send control becomes a stop control, so the same place in the layout always holds the thing you currently want -- and there is never a send button that quietly does nothing.
```tsx
}
/>
```
attachments and actions are slots either side of the control, for what is going with the message and for what changes how it is sent -- a model picker, a tool toggle -- so the composer stays yours to arrange.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `value, defaultValue, onValueChange` | `string, string, (value: string) => void` | The text, controlled or uncontrolled. |
| `onSubmit` | `(value: string) => void` | Runs with the trimmed message. |
| `status` | `"ready" | "streaming"` | Swaps the send button for a stop button. |
| `onStop` | `() => void` | Runs when the stop button is pressed. |
| `maxRows` | `number` | How far the field grows before it scrolls. Defaults to 8. |
| `attachments, actions` | `ReactNode` | Slots above the field and beside the send button. |
## Accessibility
The field has a real label and the send and stop buttons have accessible names rather than only icons. Enter sends and Shift+Enter starts a new line, but Enter is left alone while an input method editor has a candidate open, so composing text in Japanese or Chinese does not send the message early. Sending is refused when the field holds only whitespace.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Suggestions
A row of prompts to start or continue with, for the moment someone does not know what to ask.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/suggestions
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/suggestions/suggestions.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/suggestions`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/suggestions`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/suggestions`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/suggestions`
Or as a package import:
```ts
import { Suggestions } from "mischief-ui/suggestions"
```
## Usage
```tsx
const prompts = [
{ id: "summary", label: "Summarise this document" },
{ id: "risks", label: "Find the risks" },
]
export function Starters() {
return
}
```
## What is shown and what is sent
A suggestion carries a label, which is what people read, and optionally a prompt, which is what you would actually send. They are separate because a good button is short and a good prompt is not: Summarise this reads well on a chip, and does far less than the three sentences you would rather the model receive.
onSelect hands you the whole suggestion, so what you do with it is yours to decide -- send the prompt, or drop it into the composer for editing first.
```tsx
send(suggestion.prompt ?? String(suggestion.label))}
/>
```
Nothing falls back for you: decide what an absent prompt means.
## Choosing what to suggest
Suggestions are most useful when someone does not yet know what this thing can do, which means they should show range rather than repeat one idea three ways. Three or four that each open a different door beat eight that all summarise something.
Make them specific to what is actually on screen. Ask about this document earns its place; Ask a question does not, because it tells the reader nothing they had not worked out from the text box.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `suggestions` | `Suggestion[]` | Id, label, and an optional prompt and icon. |
| `onSelect` | `(suggestion: Suggestion) => void` | Runs with the chosen suggestion. |
| `disabled` | `boolean` | Disables every suggestion at once. |
| `label` | `string` | The accessible name of the row. |
## Accessibility
The row is a labelled navigation landmark holding a list of buttons, so it can be skipped or entered deliberately rather than being an unlabelled run of controls. It scrolls horizontally with snap points and every target meets the minimum touch size. Nothing is rendered at all when there is nothing to suggest.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Questionnaire
The questions an agent asks before it starts. One at a time, with single or multiple answers, an open answer alongside them, and required ones it will not move past.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/questionnaire
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/questionnaire/questionnaire.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/questionnaire`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/questionnaire`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/questionnaire`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/questionnaire`
Or as a package import:
```ts
import { Questionnaire } from "mischief-ui/questionnaire"
```
## Usage
```tsx
const questions = [
{
id: "scope",
prompt: "What should I change?",
required: true,
choices: [
{ id: "one", label: "Only this file" },
{ id: "all", label: "Every file that matches" },
],
},
]
export function Clarify() {
return
}
```
## The answer shape
Answers are a record of question id to an array of strings, whatever the question. A single-choice question holds one entry, a multiple-choice question holds several, and a freeform answer is the typed text itself. One shape means reading the result never depends on how the question was configured.
```tsx
{
"scope": ["invoices"],
"fields": ["total", "tax", "due-date"],
"notes": ["Skip anything before 2024"]
}
```
A question with required set is not satisfied until its array is non-empty, and submission stays blocked until every required question is.
## Freeform answers
Every question offers an open text answer by default, because the moment the choices do not cover the case, a fixed list forces a wrong answer. Turn it off for the whole set with freeform={false}, or per question, when the choices really are exhaustive.
```tsx
```
A question may still opt back in with freeform on itself.
## Keyboard
- Number keys pick the matching choice, and are ignored while a text field has focus.
- Tab reaches every choice and the freeform field in order.
- Enter submits once the required questions are answered.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `questions` | `Question[]` | Prompt, optional description, choices, and flags for multiple, freeform, and required. |
| `answers, defaultAnswers, onAnswersChange` | `QuestionnaireAnswers` | Chosen choice ids per question, controlled or uncontrolled. |
| `onSubmit` | `(answers: QuestionnaireAnswers) => void` | Runs with every answer once the last question is submitted. |
| `freeform` | `boolean` | Offers an open answer on every question. Defaults to true, and a question can set its own. |
| `shortcuts` | `boolean` | Number keys pick the choice they label. Defaults to true. |
| `showProgress` | `boolean` | Shows the position and a progress bar. |
| `previousLabel, nextLabel, skipLabel, submitLabel, requiredMessage` | `string` | Copy for the controls and the validation message. |
### Question
| Prop | Type | Description |
| --- | --- | --- |
| `id` | `string` | Key this question's answer is stored under. |
| `prompt` | `ReactNode` | The question itself. |
| `description` | `ReactNode` | A clarifying line beneath the prompt. |
| `choices` | `QuestionChoice[]` | Offered answers. Omit for a purely open question. |
| `multiple` | `boolean` | Allows more than one choice. |
| `freeform` | `boolean` | Overrides the set-wide setting for this question. |
| `freeformLabel, freeformPlaceholder` | `string, string` | Wording for the open answer. |
| `required` | `boolean` | Blocks submission until answered. |
### QuestionChoice
| Prop | Type | Description |
| --- | --- | --- |
| `id` | `string` | What lands in the answer array. |
| `label` | `ReactNode` | The choice as shown. |
| `description` | `ReactNode` | A second line under the choice. |
## Accessibility
Each question is a fieldset with its prompt as the legend, so the whole question is announced rather than a run of loose options. A single answer uses radios and several uses checkboxes, which brings the right keyboard behaviour without rebuilding it. Position is reported in a polite live region, and a required question that is not answered raises an alert tied to the inputs rather than only colouring them. Number shortcuts are ignored while a freeform answer is being typed.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Ask AI
Hand someone a prepared, source-aware prompt in the AI assistant they already use, or let them copy it for another one.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/ask-ai
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/ask-ai/ask-ai.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/ask-ai`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/ask-ai`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/ask-ai`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/ask-ai`
Or as a package import:
```ts
import { AskAi } from "mischief-ui/ask-ai"
```
## Usage
```tsx
const prompt = [
"Explain what Acme does using current web sources.",
"Prefer Acme's own docs, cite every claim, and flag anything unverified.",
].join("\n")
export function AskAboutAcme() {
return
}
```
## The prompt travels in a URL
Each target is a link that carries the prompt as a query parameter. Nothing is sent until someone chooses one, and then it leaves your site entirely: it lands in that assistant's logs, the reader's browser history, and anywhere a URL is ordinarily kept.
So build the prompt out of public things -- a page address, a product name, a question about documentation. Never interpolate a customer record, a file someone uploaded, an API key, or the contents of an internal page. If you would not paste it into a stranger's chat window, it does not belong in the prompt.
There is also a length limit you do not control: browsers and servers both cut long URLs off, and a very long prompt can arrive truncated. Keep it to an instruction and a link, and let the assistant fetch the rest.
## Choosing who to offer
The default set covers the assistants people are most likely to have open. Replace it with targets to cut it down, reorder it, or point at something of your own -- an internal tool, a workspace with your documentation already loaded.
The copy control is the one that always works, since it needs no third party at all. Keep it available even when you have trimmed the targets to nothing.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `subject` | `string` | The product or topic named in the heading and labels. |
| `prompt` | `string` | The complete prompt sent to or copied for an assistant. |
| `targets` | `readonly AskAiTarget[]` | Custom assistant names and prepared URLs. Defaults to ChatGPT, Claude, Perplexity, and Grok. |
| `description` | `ReactNode` | Supporting copy below the heading. |
| `copyLabel` | `string` | The idle copy button label. |
| `onPromptCopied` | `(prompt: string) => void` | Runs after the prompt reaches the clipboard. |
| `className` | `string` | Classes for the root element. |
| `...rootProps` | `HTMLAttributes` | Native root attributes. |
| `id (AskAiLogo)` | `string` | Which assistant's mark to draw. An unknown id falls back to the first letter of name. |
| `name (AskAiLogo)` | `string` | The assistant, for that fallback. |
| `className (AskAiLogo)` | `string` | Classes for the mark. |
### AskAiTarget
| Prop | Type | Description |
| --- | --- | --- |
| `id` | `string` | Unique within the set. |
| `name` | `string` | The assistant's name, as shown. |
| `href` | `string` | The full URL, with the prompt already encoded into it. |
## Accessibility
Every assistant is a named external link with a 44px target and explicit new-tab wording. The copy action is a native button. Success and failure are shown in the button and announced through a polite status region. The component does not open a destination until someone chooses it. Prompts are placed in destination URLs, so they must not contain secrets or private data.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Streaming Text
Text that arrives a piece at a time from an async source, with a cursor while it runs and sentence-level announcements for screen readers.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/streaming-text
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/streaming-text/streaming-text.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/streaming-text`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/streaming-text`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/streaming-text`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/streaming-text`
Or as a package import:
```ts
import { StreamingText } from "mischief-ui/streaming-text"
```
## Usage
```tsx
export function Answer({ stream }: { stream: AsyncIterable }) {
return
}
```
## Two ways to drive it
Pass text and it is typed out at speed, which is the right thing for a canned answer or a demonstration. Pass source -- an async iterable of chunks -- and it renders what actually arrives, at the pace it arrives, with no artificial delay in front of a real response.
```tsx
save(text)}
onError={report}
/>
```
Anything async-iterable works, including a fetch body reader.
Callbacks fire from the status they describe rather than from inside a render, so onDone runs once when the stream finishes and never during React's own work.
## What a screen reader hears
Announcing every character would be unusable, so the live region is filled a sentence at a time as sentences complete. A reader hears the answer in whole thoughts, slightly behind the text on screen, instead of a stream of letters.
Set announce to off where the text is decorative, or where something else on the page is already announcing the same content. A static render -- no streaming, no source -- fills nothing, so a transcript of past messages does not re-announce itself on mount.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `text` | `string` | Static content, or the script replayed by speed. |
| `source` | `AsyncIterable | ReadableStream` | A live source consumed once and appended as it arrives. |
| `speed` | `number` | Characters per second when replaying text. Defaults to 0, which renders instantly. |
| `streaming` | `boolean` | Forces the streaming state when the caller owns the text. |
| `cursor` | `ReactNode | false` | Replaces or removes the trailing cursor. |
| `announce` | `"sentences" | "off"` | How the live region reports progress. Defaults to "sentences". |
| `onDone` | `(text: string) => void` | Runs once the source completes. |
| `onError` | `(error: unknown) => void` | Runs when the source rejects. |
| `onStatusChange` | `(status: StreamingTextStatus) => void` | Runs on every status transition. |
## Accessibility
While text is arriving the visible node is hidden from assistive technology and a polite live region receives completed sentences instead, flushed on terminal punctuation, after a one second pause, or on completion. When the source settles the visible text is exposed normally and the live region is cleared. Static text never populates a live region. The cursor stops animating under reduced motion.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Thinking State
A status row for work in progress, with a live elapsed timer and optional reasoning behind a disclosure.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/thinking-state
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/thinking-state/thinking-state.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/thinking-state`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/thinking-state`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/thinking-state`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/thinking-state`
Or as a package import:
```ts
import { ThinkingState } from "mischief-ui/thinking-state"
```
## Usage
```tsx
export function Status({ startedAt }: { startedAt: number }) {
return (
}
/>
)
}
```
## The four states
The component shows one of four things, and each is announced politely as it changes so a reader who is not watching still learns that the answer has started or finished.
| Status | Shows |
| --- | --- |
| idle | Nothing is happening. Render it or do not, as you prefer. |
| thinking | The working indicator, and a live duration if startedAt is set. |
| done | doneLabel, and the final duration. |
| error | errorLabel in place of the label. |
Pass startedAt and the duration counts up on its own; pass elapsedMs and that fixed figure is shown instead. The second is what you want when replaying a conversation, where a live counter would start again from zero on every render of an old message.
## Showing the reasoning
reasoning goes behind a disclosure that starts closed, because the point of this component is to say that work is happening without burying the answer underneath the working. Someone curious can open it; nobody has to scroll past it.
Think about what you put in there. Intermediate reasoning is often less careful than the final answer, and once it is on screen it can be screenshotted and quoted as though it were the conclusion. A summary of the steps is usually more useful, and more defensible, than the raw trace.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `status` | `"idle" | "thinking" | "done" | "error"` | The current phase. Defaults to "thinking". |
| `label, doneLabel, errorLabel` | `ReactNode` | Copy for each phase. |
| `startedAt` | `number` | Epoch milliseconds. Drives a timer that ticks while thinking. |
| `elapsedMs` | `number` | A fixed duration, used instead of the timer when supplied. |
| `showElapsed` | `boolean` | Shows the duration. Defaults to true. |
| `reasoning` | `ReactNode` | Optional detail behind a disclosure. Compose Streaming Text here for live reasoning. |
| `open, defaultOpen, onOpenChange` | `boolean, boolean, (open: boolean) => void` | Controls the reasoning disclosure. |
## Accessibility
The root carries aria-busy while thinking and drops it once the work settles. Reasoning uses a native button with aria-expanded and aria-controls rather than a details element, so it can animate and stay predictable. The spinner and label stop animating under reduced motion.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Tool Call
A compact record of one tool invocation: name, status, duration, and the input and output behind a disclosure.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/tool-call
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/tool-call/tool-call.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/tool-call`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/tool-call`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/tool-call`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/tool-call`
Or as a package import:
```ts
import { ToolCall } from "mischief-ui/tool-call"
```
## Usage
```tsx
export function Search() {
return (
Three matches.}
durationMs={340}
/>
)
}
```
## The four states
A call moves through as many of these as it needs. Each one changes what is shown and is announced politely, naming the tool, so a reader who is not watching still learns what happened.
| Status | Shows |
| --- | --- |
| pending | Queued. The input, and nothing that has happened yet. |
| running | In flight, with a live duration if startedAt is set. |
| success | The output, and the final duration. |
| error | The failure message in place of the output. |
Pass startedAt while running and the duration counts up on its own; pass durationMs once it settles and that fixed figure is shown instead. Setting neither is fine -- the call simply reports no timing.
## Input and output
Input is rendered for you: an object is formatted as JSON, a string is shown as it is. Output is not, because only you know whether the result is a table, a paragraph, or three files. Render it and pass it in.
```tsx
}
durationMs={340}
/>
```
There is no syntax highlighting on the input, and no dependency that would provide it. Keep what you pass small enough to read: the arguments that decide what the call did, not everything that was in scope.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `name` | `string` | The tool name shown in the header. |
| `status` | `"pending" | "running" | "success" | "error"` | The current phase. Defaults to "pending". |
| `input` | `unknown` | Rendered as formatted JSON, or as-is when it is a string. |
| `output` | `ReactNode` | Whatever the tool returned, rendered by you. |
| `error` | `string` | A failure message shown inside the panel. |
| `startedAt` | `number` | Epoch milliseconds. Drives a live duration while running. |
| `durationMs` | `number` | The final duration once the call settles. |
| `icon` | `ReactNode` | Replaces the default tool icon. |
| `open, defaultOpen, onOpenChange` | `boolean, boolean, (open: boolean) => void` | Controls the detail disclosure. |
## Accessibility
Status changes are announced through a polite status region naming the tool. The disclosure is a native button with aria-expanded and aria-controls, and its accessible name says which tool it belongs to. Input is rendered as plain preformatted text in a horizontally scrollable region, with no syntax highlighting and no extra dependency.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Agent Checklist
A task list whose items change state as work proceeds, announcing what changed instead of re-reading the whole list.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/agent-checklist
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/agent-checklist/agent-checklist.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/agent-checklist`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/agent-checklist`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/agent-checklist`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/agent-checklist`
Or as a package import:
```ts
import { AgentChecklist } from "mischief-ui/agent-checklist"
```
## Usage
```tsx
const items = [
{ id: "read", label: "Read the changelog", status: "done" },
{ id: "diff", label: "Compare versions", status: "active" },
{ id: "write", label: "Draft the summary", status: "pending" },
]
export function Plan() {
return
}
```
## The five states
Every item is in exactly one state, and the wording each maps to is what a screen reader hears alongside the label.
| Status | Read as |
| --- | --- |
| pending | waiting |
| active | in progress |
| done | done |
| error | failed |
| skipped | skipped |
skipped exists so a plan that changed does not have to lie. An agent that decided a step was unnecessary should mark it skipped rather than done, which is the difference between a truthful record and a tidy one.
## Announcing progress
With announce on, each change is read out as it happens. That is genuinely helpful for a plan of five or six steps and unbearable for a plan of forty, so turn it off for long lists and let the progress count carry the story instead.
Write labels as the thing being done, short enough to be heard in one breath: Reading the invoice, not Now attempting to read the uploaded invoice document. Detail is for detail.
```tsx
```
## API
| Prop | Type | Description |
| --- | --- | --- |
| `items` | `AgentChecklistItem[]` | Id, label, status, and optional detail per step. Fully controlled. |
| `title` | `ReactNode` | An optional heading above the list. |
| `announce` | `boolean` | Announces status transitions politely. Defaults to true. |
| `showProgress` | `boolean` | Shows the settled count in the header. Defaults to true. |
### AgentChecklistItem
| Prop | Type | Description |
| --- | --- | --- |
| `id` | `string` | Unique within the list. |
| `label` | `ReactNode` | The step, phrased as the thing being done. |
| `status` | `ChecklistItemStatus` | pending, active, done, error, or skipped. |
| `detail` | `ReactNode` | A second line, for what the step actually found. |
## Accessibility
The list is an ordered list and every item states its status in text for screen readers, not through colour or icon alone. When a status changes, only the difference is announced along with a running count, so a long list does not get re-read on every update. Spinners stop under reduced motion.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Inline Citations
Numbered markers placed inside generated text, each linking to its entry in a source list underneath.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/inline-citations
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/inline-citations/inline-citations.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/inline-citations`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/inline-citations`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/inline-citations`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/inline-citations`
Or as a package import:
```ts
import { InlineCitations } from "mischief-ui/inline-citations"
```
## Usage
```tsx
const sources = [
{ id: "docs", title: "Agent UI docs", url: "https://example.com/docs" },
]
export function Answer() {
return (
Streaming is supported.
)
}
```
## How references are numbered
Numbers come from the position of a source in the sources array, not from the order the citations appear in the text. Two mentions of the same source are therefore the same number wherever they fall, and reordering a paragraph never renumbers anything.
```tsx
The window is fifteen minutes , and the counter
resets on success rather than on expiry{" "}
.
)
}
```
## What each token becomes
Write the chord the way you think about it and let the platform decide how it is spelled. Mod is the one that matters: it is Command on Apple platforms and Control everywhere else, which is exactly the distinction most shortcut hints get wrong by hard-coding one of them.
| Token | Apple | Elsewhere |
| --- | --- | --- |
| Mod | ⌘ | Ctrl |
| Alt or Option | ⌥ | Alt |
| Shift | ⇧ | Shift |
| Ctrl | ⌃ | Ctrl |
| Enter | ↵ | Enter |
| Escape | Esc | Esc |
Anything unrecognised is passed through, with a single letter upper-cased, so Mod+K and Mod+Shift+P both read correctly without a special case.
## It only says the shortcut
Nothing is bound. This renders a hint and no more, so the keys shown and the keys that work are kept in step by you. Where a component already owns the shortcut -- the command palette and its Mod+K, say -- name the same chord here rather than inventing a second source of truth.
```tsx
Press to search, or to close.
```
## API
| Prop | Type | Description |
| --- | --- | --- |
| `keys` | `string | string[]` | A chord such as "Mod+K", or the keys already split apart. |
| `platform` | `"auto" | "mac" | "other"` | Overrides detection. Defaults to auto. |
| `separator` | `ReactNode` | Placed between keys. Omitted by default. |
## Accessibility
Glyphs are decoration: the chord is also written out for a screen reader, so it hears "Command plus K" rather than a symbol it cannot pronounce. Detection runs through useSyncExternalStore, so the server renders the portable names and the client swaps in the Mac glyphs on hydration. Mod resolves to Command on Apple platforms and Control everywhere else.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Voice Input
A microphone that draws what it is hearing, so a live one is told apart from a dead one at a glance.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/voice-input
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/voice-input/voice-input.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/voice-input`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/voice-input`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/voice-input`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/voice-input`
Or as a package import:
```ts
import { VoiceInput } from "mischief-ui/voice-input"
```
## Usage
```tsx
export function Composer() {
return (
transcribe(recording)}
/>
)
}
```
## It records; it does not transcribe
Turning speech into text is a service, not a component. Putting one inside something you copy into your own project would decide your vendor, your billing and your privacy posture on your behalf, so this stops at the recording and hands it to you.
```tsx
{
const body = new FormData()
body.append("audio", recording, "speech.webm")
setText(await (await fetch("/api/transcribe", { method: "POST", body })).text())
}}
/>
```
The recording is a Blob, so it posts like any other file.
## Why it draws
A microphone button that only changes colour asks to be trusted. There is no way to tell a working microphone from a muted one, a wrong input device, or a permission that was granted to the page and then revoked by the operating system, until the recording comes back empty.
Drawing the incoming samples settles it in the first half second: if the trace moves when you speak, the microphone the browser handed over is the one you are talking into.
The trace is drawn on the shared render surface, so it takes its colour from your theme, stops when it is scrolled out of view, and survives a lost GPU context like every other surface here.
## The states it can be in
A refusal, a missing device and a browser that cannot record are three different problems with three different remedies, so they are three different messages rather than one failure.
| Status | What happened |
| --- | --- |
| unsupported | No MediaRecorder, so the control is disabled rather than dead |
| idle | Ready, nothing held |
| requesting | Waiting on the permission prompt |
| listening | Recording, and drawing what it hears |
| denied | Permission refused |
| error | The device could not be started |
The status is also on the element as data-status, so a composer can style around it without lifting the state.
## Letting go of the microphone
The recording indicator staying lit after a component thinks it has stopped is the usual bug here, and it is a privacy one. Every track is stopped and the audio context is closed when recording ends, when the component unmounts, and when a start fails partway through.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `onResult` | `(recording: Blob) => void` | The audio, once recording stops. |
| `onStart, onStop` | `() => void` | Either end of a recording. |
| `onStatusChange` | `(status: VoiceInputStatus) => void` | Every state change, if you are mirroring it elsewhere. |
| `maxDuration` | `number` | Seconds after which it stops on its own. Off by default. |
| `color` | `string` | A theme token for the trace. Defaults to "--primary". |
| `mimeType` | `string` | Preferred container. Ignored when the browser cannot honour it. |
| `label` | `string` | The button's accessible name when idle. |
| `disabled` | `boolean` | Turns the control off without changing its state. |
## Accessibility
The button carries aria-pressed, so the difference between recording and not is in the accessibility tree rather than only in the icon. Every state change is announced through a polite live region, and the visible message is marked aria-hidden because it is the same sentence: it is said once, not once on screen and once aloud. The trace is decoration and never carries meaning the words do not, which matters because under prefers-reduced-motion it is not drawn at all -- a single painted frame would sit frozen while the microphone was open, so the words and the elapsed time take over instead.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Audio Player
A recording with its shape, its position and its words, so a voice note can be read as well as heard.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/audio-player
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/audio-player/audio-player.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/audio-player`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/audio-player`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/audio-player`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/audio-player`
Or as a package import:
```ts
import { AudioPlayer } from "mischief-ui/audio-player"
```
## Usage
```tsx
export function VoiceNote({ recording, lines }) {
return
}
```
## Give it the peaks if you have them
Drawing a waveform means knowing the amplitude across the whole file, and the only way to learn that in a browser is to decode it. Decoding holds the audio uncompressed: an hour of speech is a few hundred megabytes of Float32, arriving all at once.
So peaks is a real prop rather than an internal detail. Compute them once where the file is uploaded, store them beside it, and every play after that skips the decode entirely.
```tsx
```
Amplitudes from 0 to 1, one per bar. Anything from 32 to a few hundred reads well.
Without them it decodes, and stops short at maxDecodeBytes. Past that ceiling the audio still plays and only the picture is given up, which is the right way round.
## A picture is not a control
The waveform is drawn on a canvas, and a canvas cannot be tabbed to, dragged with a keyboard, or read out. So the thing that seeks is an ordinary range input lying over the drawing, transparent, at the full size of it.
That leaves the picture optional and the control intact. Arrow keys step through the audio, Home and End reach either end, the value is announced as a position in minutes and seconds rather than as a number of seconds, and the focus ring is drawn around the whole scrubber.
## The transcript shape
A line is a start, an optional end, the text, and optionally who said it. Every speech service emits at least that much, so a transcript from one of them normalises in a few lines rather than tying this component to whichever one you picked.
```tsx
const lines = response.segments.map((segment) => ({
start: segment.start,
end: segment.end,
text: segment.text,
}))
```
Whisper, in full.
Lines are buttons, so the transcript is a way to move through the audio and not only a thing to read. The line under the playhead is marked with aria-current and scrolls itself into view while the audio is playing, instantly where reduced motion is preferred.
## Recordings that do not know how long they are
A file from MediaRecorder, which is what Voice Input hands you, carries no duration in its header. The browser reports Infinity for it until it is asked to look, which leaves a player with no scale to draw against and a scrubber with no end.
Seeking past the end is what makes it look. That happens here on the first metadata event, so a recording made in the same page plays with a real length like any other file.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `src` | `string | Blob` | A URL, or a recording you already hold. |
| `waveform` | `boolean` | Draw the audio behind the scrubber. |
| `peaks` | `readonly number[]` | Amplitudes from 0 to 1. Supplying these skips decoding. |
| `transcript` | `readonly TranscriptLine[]` | Timed lines, shown under the controls. |
| `rates` | `readonly number[]` | Speeds the button cycles through. Defaults to 1, 1.5 and 2. |
| `maxDecodeBytes` | `number` | Above this the audio plays without a drawn waveform. Defaults to 40MB. |
| `color` | `string` | A theme token for the played portion. Defaults to "--primary". |
| `label` | `string` | Names the recording in every control's label. |
| `...rootProps` | `HTMLAttributes` | Native root attributes. |
### TranscriptLine
| Prop | Type | Description |
| --- | --- | --- |
| `start` | `number` | Seconds from the beginning of the audio. |
| `end` | `number` | Seconds. Only needed when lines do not run back to back. |
| `text` | `string` | What was said. |
| `speaker` | `string` | Who said it, shown before the line. |
## Accessibility
Seeking is a native range input with an accessible name and a value announced as a position in minutes and seconds, so it works with arrow keys, Home and End, and a screen reader. The waveform behind it is decoration and carries nothing the time display does not. Transcript lines are buttons, which makes every line reachable without a pointer, and the current one is marked with aria-current rather than by colour alone. Play, speed and every transcript line meet the 44px touch target. Nothing autoplays.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Bar Visualizer
Frequency bars for audio on its way out, the counterpart to the trace a microphone draws on the way in.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/bar-visualizer
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/bar-visualizer/bar-visualizer.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/bar-visualizer`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/bar-visualizer`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/bar-visualizer`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/bar-visualizer`
Or as a package import:
```ts
import { BarVisualizer } from "mischief-ui/bar-visualizer"
```
## Usage
```tsx
export function Speaking({ audio }) {
return
}
```
## What it can be given
A MediaStream for audio arriving, an audio or video element for audio playing, or an AnalyserNode you already built and would rather keep control of.
| Source | When |
| --- | --- |
| MediaStream | A microphone, or a track from a call |
| HTMLMediaElement | A reply being played back |
| AnalyserNode | You already have a graph and want one tap |
An element can be handed to createMediaElementSource exactly once, and a second attempt throws for the rest of the page's life. Development remounts every effect twice, so that tap is kept and reused rather than rebuilt, and the element goes back to the speakers when this component unmounts.
## It says the state as well as drawing it
Bars that move are the whole point of this, which makes it useless under prefers-reduced-motion: a single painted frame would show a bar chart frozen mid-sentence, saying nothing about whether anything is happening.
So state is a prop rather than something inferred from the levels. It carries the answer in words through a live region in both cases, and where motion is reduced the bars fall back to an even resting row instead of a misleading still.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `source` | `MediaStream | HTMLMediaElement | AnalyserNode | null` | The audio to draw. Without one the bars rest. |
| `state` | `"idle" | "listening" | "speaking"` | What is happening, announced and put on the element. |
| `bars` | `number` | How many bars to draw. Defaults to 24. |
| `color` | `string` | A theme token for the bars. Defaults to "--primary". |
| `...rootProps` | `HTMLAttributes` | Native root attributes. |
## Accessibility
The state is announced through a polite live region in words, so a reader who cannot see the bars still learns that the assistant is listening or speaking. It is also on the element as data-state. Under prefers-reduced-motion the drawing is replaced by a still row of bars rather than a frozen frame, because a stopped visualiser reads as a stopped conversation.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Mic Selector
Chooses which microphone to use, then settles the question by lighting a meter from the one you chose.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/mic-selector
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/mic-selector/mic-selector.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/mic-selector`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/mic-selector`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/mic-selector`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/mic-selector`
Or as a package import:
```ts
import { MicSelector } from "mischief-ui/mic-selector"
```
## Usage
```tsx
export function Setup() {
const [device, setDevice] = React.useState("")
return
}
```
## Why the names are missing at first
A page that has never been granted the microphone can count the devices but not read their labels. Browsers withhold them because a list of attached hardware identifies a machine well enough to track it.
So an untested list reads Microphone 1 and Microphone 2, and fills in with real names the moment the test grants permission. Showing numbered placeholders is honest about that. Asking for the microphone on mount, purely to read the labels, is not.
## Testing opens the device
The meter needs the microphone open, which is a permission prompt and a recording indicator, so it happens when the test is pressed and not before. Pressing it again, changing device, or leaving the page closes the stream and the audio context.
The meter reads loudness rather than the loudest sample, so a chair creak does not light the whole row and leave it there. It is drawn as segments that turn on and off, which means it stays readable under reduced motion instead of being replaced by something else.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `value` | `string` | The chosen device id, when you are holding the value. |
| `defaultValue` | `string` | The device to start on, uncontrolled. |
| `onValueChange` | `(deviceId: string) => void` | A different microphone was chosen. |
| `onStatusChange` | `(status: MicSelectorStatus) => void` | Every state change, if you are mirroring it elsewhere. |
| `segments` | `number` | Segments in the meter. Defaults to 12. |
| `label` | `string` | Names the list and the test button. |
| `disabled` | `boolean` | Turns the control off without changing its state. |
| `...rootProps` | `HTMLAttributes` | Native root attributes. |
## Accessibility
The list is a native select, so it works with a keyboard and reads out as a list of options without any ARIA. The test button is a real toggle with aria-pressed and a name that says which way it will go. Every state, including a refused permission and a browser that cannot enumerate at all, is announced through a polite live region and written on the element as data-status. The meter is decoration: it is hidden from assistive technology, and the same information is in the message beside it.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Chain of Thought
The steps an assistant took before answering, open while it is working and folded away once it is done.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/chain-of-thought
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/chain-of-thought/chain-of-thought.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/chain-of-thought`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/chain-of-thought`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/chain-of-thought`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/chain-of-thought`
Or as a package import:
```ts
import { ChainOfThought } from "mischief-ui/chain-of-thought"
```
## Usage
```tsx
export function Answer({ steps, running }) {
return
}
```
## The trace is never announced
Reasoning arrives a token at a time. Put it in a live region and a screen reader reads every revision of every half-finished step, over the top of the answer the person actually asked for. It is the loudest possible way to be helpful.
So the steps are ordinary text, reachable on purpose, and the only thing announced is the summary line: working on it, then thought for four seconds. Each step's status is in its accessible name rather than in the colour of a dot.
## It opens and closes itself, once
Open while thinking and folded away when the answer arrives is right, because the reasoning is interesting while there is nothing else to look at and clutter afterwards.
It stops deciding the moment someone touches it. Collapsing a trace under a reader who opened it to read is the rudest thing this component could do, so the first click hands control over for good.
```tsx
```
Or hold it yourself, and it never decides at all.
## What a step can be
| Status | What it means |
| --- | --- |
| pending | Planned, not started |
| active | Running now |
| done | Finished, and how long it took |
| failed | Tried and did not work |
A step with no status is done. Each one is also written on its element as data-status, so a thread can style around it without lifting the state.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `thoughts` | `readonly Thought[]` | The steps, in the order they ran. |
| `thinking` | `boolean` | Still working. The trace opens itself while this is true. |
| `open` | `boolean` | Hold the disclosure yourself. |
| `defaultOpen` | `boolean` | Start open, and never decide again. |
| `onOpenChange` | `(open: boolean) => void` | Someone opened or closed it. |
| `...rootProps` | `HTMLAttributes` | Native root attributes. |
### Thought
| Prop | Type | Description |
| --- | --- | --- |
| `id` | `string` | Unique within the trace. |
| `label` | `string` | The step, short enough to read in one breath. |
| `detail` | `ReactNode` | What it found, under the label. |
| `status` | `"pending" | "active" | "done" | "failed"` | Defaults to done. |
| `duration` | `number` | Seconds this step took. |
## Accessibility
The disclosure is a button carrying aria-expanded, so the trace can be opened from a keyboard and its state is read out. Only the summary is in a polite live region: streaming reasoning is deliberately kept out of one, because announcing every token would talk over the answer. A step's status reaches a screen reader as words in its accessible name rather than as the colour of its marker, and the summary stops pulsing when reduced motion is preferred.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Web Preview
A sandboxed frame for whatever the assistant just built, with the widths to check it at.
- Family: Code
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/web-preview
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/web-preview/web-preview.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/web-preview`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/web-preview`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/web-preview`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/web-preview`
Or as a package import:
```ts
import { WebPreview } from "mischief-ui/web-preview"
```
## Usage
```tsx
export function Result({ url }) {
return
}
```
## The sandbox is most of the component
Generated code goes in a frame because it cannot be trusted, and the default here is scripts, forms, popups and modals. What it deliberately leaves out is allow-same-origin.
Granting allow-scripts and allow-same-origin together undoes the sandbox completely: the framed page shares your origin, so its script can reach the frame element in the parent document and rewrite the sandbox attribute it is meant to be held by. Widen this only for content you wrote.
The cost of leaving it out is that the frame has no origin at all, so a request from inside it arrives without one: fonts served from your own domain fail CORS, cookies do not travel, and storage is empty. A preview looking slightly wrong in the typeface is the sandbox working.
```tsx
```
Narrower is always available. Wider needs a reason.
## The address is what you asked for
It is not where the frame ended up. A page on another origin will not tell you its own location, and reading it throws, so the bar shows the address that was requested and stops making promises after that.
Reload works by mounting the frame again rather than by calling into it, for the same reason. The link out is a real anchor to the same address, so it can be middle-clicked and copied like any other.
## Widths, scaled rather than cut off
A phone width inside a panel narrower than a phone is scaled down, not clipped. The page inside still believes it has 390 pixels, which is the only way the media queries it was built with actually run.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `src` | `string` | What to show. |
| `title` | `string` | Names the frame. Required: an unnamed frame is announced as nothing else. |
| `sizes` | `readonly PreviewSize[]` | Widths to offer. Phone, tablet, full. |
| `defaultSize` | `string` | Which width to open at. |
| `editable` | `boolean` | Let someone type a different address. |
| `onNavigate` | `(src: string) => void` | A new address was entered. |
| `sandbox` | `string` | Replaces the default. Read the note before widening it. |
| `height` | `number` | Pixels. Defaults to 420. |
| `...rootProps` | `HTMLAttributes` | Native root attributes. |
### PreviewSize
| Prop | Type | Description |
| --- | --- | --- |
| `id` | `string` | Unique within the set. |
| `label` | `string` | Shown on the button. |
| `width` | `number` | Pixels. Zero fills the panel. |
## Accessibility
The frame carries a required title, because one without a name is announced as "frame" and nothing more. The address is a labelled input rather than styled text, so it can be focused, read and copied, and it is read-only until editing is turned on. Width choices are a labelled group of toggles carrying aria-pressed. The link out says where it goes and that it opens in a new tab. Every control is at least 32px with a visible focus ring.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Video Player
Video with captions that can be turned on, a real scrubber, and controls that survive full screen.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/video-player
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/video-player/video-player.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/video-player`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/video-player`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/video-player`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/video-player`
Or as a package import:
```ts
import { VideoPlayer } from "mischief-ui/video-player"
```
## Usage
```tsx
export function Recording({ src, captions }) {
return
}
```
## Captions are a track, not a transcript
A caption belongs to a moment in the video, which is what WebVTT and the track element already express. Rendering your own list of lines beside the picture is a different thing, useful for reading and useless for watching.
```tsx
```
Taking the browser's controls away takes its caption menu with them, so the track mode is set here by hand. The browser still draws the cues over the picture once a track is showing, which is why the toggle is three lines rather than a caption renderer.
## Owning the controls means owning all of them
Play, mute, speed, captions and full screen are each a real button with a name that says which way it will go, and the pressed ones carry aria-pressed. Seeking is a range input, so arrow keys, Home and End work and the position is announced as a time rather than a count of seconds.
Full screen is requested on the whole component rather than on the video, so the controls come along instead of being replaced by the browser's own.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `src` | `string` | The video. |
| `label` | `string` | Names the video in every control's label. |
| `poster` | `string` | Shown before playback starts. |
| `tracks` | `readonly VideoTrack[]` | Caption and subtitle tracks. |
| `rates` | `readonly number[]` | Speeds the button cycles through. Defaults to 1, 1.5 and 2. |
| `...rootProps` | `HTMLAttributes` | Native root attributes. |
### VideoTrack
| Prop | Type | Description |
| --- | --- | --- |
| `src` | `string` | The WebVTT file. |
| `srcLang` | `string` | A BCP 47 tag, such as "en". |
| `label` | `string` | How the track is named to a reader. |
| `kind` | `"captions" | "subtitles"` | Captions carry sound, subtitles carry speech. Defaults to captions. |
| `default` | `boolean` | Start with this one showing. |
## Accessibility
The video carries an accessible name, and every control is a button whose name says the action it will take rather than the state it is in. Toggles carry aria-pressed. Seeking is a native range input announced as a position in minutes and seconds, so it works from a keyboard. Captions are real tracks, which means the browser draws them, the reader can style them in their own settings, and a deaf viewer gets them without a transcript being bolted on beside the picture. Full screen is entered on the component so the controls remain.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Reviewable Diff
A proposed change reviewed a hunk at a time: take three of the seven, leave the rest, apply what you took.
- Family: Code
- Kind: block
- Page: https://ui.tinkererslabs.com/docs/components/reviewable-diff
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/reviewable-diff/reviewable-diff.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/reviewable-diff`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/reviewable-diff`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/reviewable-diff`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/reviewable-diff`
Or as a package import:
```ts
import { ReviewableDiff } from "mischief-ui/reviewable-diff"
```
## Usage
```tsx
export function Review({ before, after }) {
return (
stage(hunks)}
/>
)
}
```
## All or nothing is the wrong shape
Diff View accepts or rejects a whole change, which is right when a person wrote it and knew what they meant. A patch from an agent is different: the part that fixes the bug and the part that misread the codebase usually arrive together.
Rejecting the lot to avoid one bad hunk throws away the work. Accepting the lot to keep the good hunk lets the bad one in. So the unit of the decision is the hunk, and apply hands back only the ones that were staged.
```tsx
apply(hunks)}
/>
```
onApply receives the staged hunks, in file order, and nothing else.
## It borrows the diff, not the display
The line comparison and the hunk splitting come from Diff View, which already exports both. Installing this installs that too, which is why it is a block rather than a component: one diff implementation arrives, not two that drift, and the one it sits on is worth having on its own.
Pass hunks directly when you have a real patch from git, and no comparison runs at all. Pass before and after, and it computes them with the context you ask for.
## Saying how much is staged
The header counts staged hunks and the lines they carry, and it updates as boxes are ticked. Unstaged hunks stay visible and dimmed rather than disappearing, because a hunk that vanishes when you untick it makes the patch harder to reason about, not easier.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `before, after` | `string` | The two versions, compared here. |
| `hunks` | `readonly DiffHunk[]` | Already split, when you have a real patch. Skips the comparison. |
| `filename` | `string` | Shown in the header. |
| `context` | `number` | Unchanged lines around a change. Defaults to 3. |
| `defaultStaged` | `readonly number[]` | Hunk indexes that start staged. Defaults to all of them. |
| `staged` | `readonly number[]` | Hold the staged set yourself. |
| `onStagedChange` | `(staged: number[]) => void` | The staged set changed. |
| `onApply` | `(hunks: DiffHunk[]) => void` | Apply was pressed, with the staged hunks. |
| `applyLabel` | `string` | The apply button's text. |
| `...rootProps` | `HTMLAttributes` | Native root attributes. |
## Accessibility
Each hunk is a native checkbox inside its own label, so the whole row is a target, the set is walkable with Tab and Space, and no ARIA is invented for a control the platform already has. The group carries a name that includes the filename. How much is staged is announced through a polite live region as hunks and lines rather than left to the header's colour, and an unstaged hunk is dimmed and unticked rather than hidden, so it stays readable and reachable.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Subagent Tree
Several agents working at once, nested under whoever handed the work down, each with its own state and elapsed time.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/subagent-tree
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/subagent-tree/subagent-tree.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/subagent-tree`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/subagent-tree`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/subagent-tree`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/subagent-tree`
Or as a package import:
```ts
import { SubagentTree } from "mischief-ui/subagent-tree"
```
## Usage
```tsx
export function Fleet({ runs }) {
return
}
```
## It is nested lists, not a tree widget
The obvious reach here is role=tree with treeitem children and arrow-key navigation. It would be wrong. Nothing in this is expanded, selected or activated: it is work being watched, not a file browser.
A tree widget would announce "tree, level 2, 3 of 4" over that, and take the arrow keys hostage to move between things nobody can do anything to. Nested lists say the same shape, cost nothing, and leave the keyboard alone. Reach for the widget when the nodes become controls.
## What a run can be
| Status | What it means |
| --- | --- |
| queued | Handed out, not started |
| running | Working now |
| done | Finished |
| failed | Stopped without finishing |
Status reaches a screen reader as a word after the label rather than as the colour of a dot, and the running marker stops pulsing when reduced motion is preferred. The count of what is running is announced once for the whole tree, not once per agent, because twelve agents finishing is one piece of news.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `runs` | `readonly AgentRun[]` | The top level agents. |
| `label` | `string` | Names the group. Defaults to Agents. |
| `...rootProps` | `HTMLAttributes` | Native root attributes. |
### AgentRun
| Prop | Type | Description |
| --- | --- | --- |
| `id` | `string` | Unique within the tree. |
| `label` | `string` | What this agent was asked to do. |
| `status` | `"queued" | "running" | "done" | "failed"` | Defaults to done. |
| `detail` | `ReactNode` | What it is doing, or what it found. |
| `duration` | `number` | Seconds it has taken. |
| `children` | `readonly AgentRun[]` | Work it handed down. |
## Accessibility
Nesting is expressed with nested lists, so a screen reader announces the depth and the number of items at each level without a tree widget's keyboard contract being invented for content that cannot be operated. Each run's status is spoken as a word, never carried by colour alone. One polite live region summarises the whole tree, so a fleet finishing is announced once rather than once per agent. The running marker respects reduced motion.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Stopped Run
What the thread says after an answer ended early, with whatever it had already written kept above the line.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/stopped-run
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/stopped-run/stopped-run.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/stopped-run`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/stopped-run`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/stopped-run`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/stopped-run`
Or as a package import:
```ts
import { StoppedRun } from "mischief-ui/stopped-run"
```
## Usage
```tsx
export function Ended({ text }) {
return (
{text}
)
}
```
## Keep what it wrote
Clearing the half-written answer when a run ends is the tidy choice and the wrong one. Someone stopped it because they had read enough, and the part they read is usually the part they wanted.
So the text stays, faded at its bottom edge, with a rule and a sentence underneath saying it ended and why.
## Half a sentence is not an answer
The fade says incomplete to someone who can see it and nothing at all to someone who cannot. A screen reader would otherwise read the fragment in the same voice as a finished answer and stop, which is how a truncated instruction becomes a followed one.
The partial text is a group named as an incomplete answer, so it is announced as such before it is read, and the reason is announced through a live region as soon as it appears.
## Why it ended
| Reason | What it says |
| --- | --- |
| stopped | You stopped this answer |
| error | Something went wrong |
| limit | It reached its length limit |
| timeout | It took too long and was cut off |
Carry on is offered only where carrying on makes sense, which is why it is a prop rather than something inferred: a stopped answer can be resumed and a failed one usually has to be run again.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `reason` | `"stopped" | "error" | "limit" | "timeout"` | Why it ended. Defaults to stopped. |
| `children` | `ReactNode` | What arrived before it ended. |
| `message` | `string` | Replaces the default sentence. |
| `elapsed` | `number` | Seconds it ran for. |
| `onRetry` | `() => void` | Run it again from the start. |
| `onResume` | `() => void` | Carry on from where it stopped. |
| `retryLabel, resumeLabel` | `string` | The two buttons' text. |
| `...rootProps` | `HTMLAttributes` | Native root attributes. |
## Accessibility
The partial answer is a group named as incomplete, so assistive technology says so before reading it rather than delivering a fragment in the voice of a finished answer. The reason is announced through a polite live region and is also written on the element as data-reason. The fade over the text is decoration and repeats nothing that is not in the sentence below it. Both actions are ordinary buttons at 32px with visible focus.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Memory Chips
Everything an assistant has been told to remember about someone, each one removable on its own.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/memory-chips
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/memory-chips/memory-chips.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/memory-chips`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/memory-chips`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/memory-chips`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/memory-chips`
Or as a package import:
```ts
import { MemoryChips } from "mischief-ui/memory-chips"
```
## Usage
```tsx
export function Settings({ memories }) {
return
}
```
## Memory you cannot see cannot be corrected
An assistant that quietly keeps notes on someone is holding a profile they have never read. The first thing this component does is show it, in the words it was stored in, not a summary of them.
The second is to let one entry go without taking the rest. Where forgetting everything is the only control, a single wrong note turns into a choice between living with it and starting over, so most people live with it.
## It removes nothing by itself
onForget is called with an id and that is all that happens here. Deleting the record, telling the server, and deciding whether the assistant may write it again are yours, because they are decisions about someone's data and they do not belong in a component you copied in.
```tsx
forget(id)}
onForgetAll={() => forgetEverything()}
/>
```
Leave onForgetAll off and there is no way to clear it all.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `memories` | `readonly Memory[]` | What is being remembered. |
| `onForget` | `(id: string) => void` | One entry was dismissed. |
| `onForgetAll` | `() => void` | Clear everything. Omit to withhold it. |
| `label` | `string` | Names the group. |
| `emptyMessage` | `string` | Shown when there is nothing stored. |
| `...rootProps` | `HTMLAttributes` | Native root attributes. |
### Memory
| Prop | Type | Description |
| --- | --- | --- |
| `id` | `string` | Unique within the set. |
| `text` | `string` | The note, as it was stored. |
| `source` | `string` | Where it came from, shown after the note. |
## Accessibility
Each remove button names the entry it will remove, so a screen reader hears "Forget: prefers pnpm" rather than a row of identical Remove buttons. A removal is confirmed through a polite live region, which matters because the chip it was announced from has gone by the time the message lands. The list is a real list, so its length is announced, and the empty state is a sentence rather than an absence.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Orb
A sphere that carries what an assistant is doing, settled when idle and moving with the voice when there is one.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/orb
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/orb/orb.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/orb`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/orb`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/orb`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/orb`
Or as a package import:
```ts
import { Orb } from "mischief-ui/orb"
```
## Usage
```tsx
export function Assistant() {
return
}
```
## Four states, four paces
Idle settles, listening breathes, thinking turns over faster, and speaking moves with the voice. The pace is the difference: a reader learns which is which in a few seconds without a legend.
Only listening and speaking read the level. The other two keep their own rhythm, so a stale level left over from a finished turn cannot make a thinking orb pulse as though somebody were talking.
## Feeding it a level
level is a number between nought and one, and it is read inside the frame loop rather than through a re-render. Handing it sixty values a second from an analyser costs one canvas frame, not sixty renders of the tree around it.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `state` | `"idle" | "listening" | "thinking" | "speaking"` | What the assistant is doing. Defaults to "idle". |
| `level` | `number` | How loud it is now, nought to one. |
| `color` | `string` | A theme token or CSS colour. Defaults to "--primary". |
| `size` | `number` | Width and height in pixels. Defaults to 120. |
| `label` | `string` | Said aloud in place of the state name. |
## Accessibility
The orb is a drawing, so what it means is also written: the state, or a label of your own, is announced through a polite live region. Nothing about the assistant's condition is carried by the animation alone, which matters because the animation is exactly what a reduced-motion setting or a sleeping surface will take away. The canvas itself is hidden from assistive technology.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Matrix
A grid of cells lit from the bottom by a level, which reads as a piece of hardware rather than as a chart.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/matrix
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/matrix/matrix.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/matrix`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/matrix`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/matrix`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/matrix`
Or as a package import:
```ts
import { Matrix } from "mischief-ui/matrix"
```
## Usage
```tsx
export function Meter() {
return
}
```
## Why it fills from the floor
Cells light from the bottom up, so height is loudness and the grid reads the way an equaliser does. Scattering lit cells at random across the box would be prettier for a second and unreadable after that.
With nothing to show it keeps a low shimmer along the floor rather than going black, so a quiet microphone is told apart from a dead one. Turn that off with idle when the silence is the message.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `columns` | `number` | Cells across. Rows follow from the box. Defaults to 24. |
| `level` | `number` | Nought to one. |
| `idle` | `boolean` | Keeps a low shimmer when there is no level. On by default. |
| `color` | `string` | A theme token or CSS colour. Defaults to "--primary". |
| `gap` | `number` | Pixels between cells. Defaults to 2. |
| `label` | `string` | Names the canvas when the grid is content rather than decoration. |
## Accessibility
The grid is decoration by default and hidden from assistive technology, because a level meter beside a control that already says what it is doing has nothing to add. Give it a label only where the meter is the only thing carrying the state. It sleeps when scrolled out of view like every surface here, and rebuilds its cells on resize so the grid stays square rather than stretching.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Response
An assistant's answer as markdown, including while it is still half-written and briefly invalid.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/response
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/response/response.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/response`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/response`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/response`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/response`
Or as a package import:
```ts
import { Response } from "mischief-ui/response"
```
## Usage
```tsx
export function Answer({ text, streaming }) {
return {text}
}
```
## Markdown that is not finished yet
Text arriving a token at a time is markdown that is invalid for most of its life: a fence opened three lines ago and not yet closed, a bold marker with nothing after it. Rendered as it stands, the reply fills with stray asterisks and a code block that swallows everything after it.
While streaming is set, the unterminated markers are closed for the length of that render and reopened by the next token. The reader sees a code block that grows rather than a page that breaks and repairs itself.
## Code inside an answer
A fenced block becomes a panel and inline code stays inline, because a panel around three words in the middle of a sentence is a worse answer than the sentence. The panel is deliberately plain: a component should be one thing you install, so this one does not drag a code viewer in behind it. Pass renderCode and it will use whatever you already have.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `children` | `string` | The markdown, whole or partial. |
| `streaming` | `boolean` | Closes unterminated markers for this render. Off by default. |
| `renderCode` | `(code: string, language?: string) => ReactNode` | Renders a fenced block. Pass Code Block here for the copy control; the default is a plain panel so this installs alone. |
## Accessibility
The answer is ordinary prose in the accessibility tree: headings are headings, lists are lists, and code blocks are code. It is not itself a live region, and should not be put inside one -- a reply announced token by token as it grows is unusable, and the thing worth announcing is that it started and that it finished.
## Dependencies
- react-markdown
- remark-gfm
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Transcript Viewer
A recording as text, where every line is also the way back to the moment it was said.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/transcript-viewer
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/transcript-viewer/transcript-viewer.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/transcript-viewer`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/transcript-viewer`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/transcript-viewer`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/transcript-viewer`
Or as a package import:
```ts
import { TranscriptViewer } from "mischief-ui/transcript-viewer"
```
## Usage
```tsx
export function Recording() {
return (
player.seek(cue.start)}
/>
)
}
```
## Reading and scrubbing are one act
Give it the position and it marks the line being spoken; click a line and it hands you the second to seek to. Neither is a separate mode, which is the whole point: people look for a sentence they remember, not for a timestamp they never knew.
A cue with no end is treated as running until the next one begins, so a transcript from a service that only reports start times needs no preparation.
## Following without hijacking
While it follows, the active line is scrolled into view by the nearest amount that puts it on screen, never centred. Centring on every cue drags the page under a reader who was looking at something else a moment ago.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `cues` | `TranscriptCue[]` | The transcript, in order. |
| `time` | `number` | Where the recording is now, in seconds. |
| `onSeek` | `(cue: TranscriptCue) => void` | A line was chosen. |
| `follow` | `boolean` | Scrolls to the active line. On by default. |
| `label` | `string` | Names the list. Defaults to "Transcript". |
### TranscriptCue
| Prop | Type | Description |
| --- | --- | --- |
| `id` | `string` | Distinct within the transcript. |
| `start` | `number` | Seconds from the beginning. |
| `end` | `number` | Optional. Defaults to the next cue's start. |
| `speaker` | `string` | Shown before the line. |
| `text` | `string` | What was said. |
## Accessibility
Every line is a real button, so the transcript is operable from the keyboard without any arrow-key handling of its own, and the line being spoken carries aria-current rather than only a background colour. Times are rendered in minutes and seconds instead of as a raw number of seconds, which is what a screen reader would otherwise read aloud.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Stop Generating
The control that interrupts a running answer, with the time it has been going and Escape wired up.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/stop-generating
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/stop-generating/stop-generating.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/stop-generating`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/stop-generating`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/stop-generating`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/stop-generating`
Or as a package import:
```ts
import { StopGenerating } from "mischief-ui/stop-generating"
```
## Usage
```tsx
export function Composer() {
return (
)
}
```
## Where it belongs
Put it where the send control was. Someone who has just started an answer is still looking at that spot, and a stop button somewhere else costs them a search at exactly the moment they want it to be over. Prompt Input does this for you by swapping its own control while streaming.
It renders nothing when there is nothing to stop, rather than dimming. A disabled stop button is a small lie: it suggests the option exists and is unavailable, when in fact there is simply no work in flight.
## Escape, and the promise it makes
Escape is bound while running and unbound the moment it stops, so the key never quietly does something on a page where nothing is happening. Turn it off with shortcut={false} where Escape already belongs to something else -- a dialog holding the composer, for instance, which should close rather than interrupt.
Whatever onStop does, it should genuinely stop: abort the request, not just hide the text. A stop that only stops the display leaves the model running, the bill accruing, and the answer arriving anyway if the component remounts.
```tsx
const controller = useRef(null)
controller.current?.abort()}
/>
```
## API
| Prop | Type | Description |
| --- | --- | --- |
| `onStop` | `() => void` | Called on click, and on Escape while the shortcut is on. |
| `running` | `boolean` | Renders nothing while false. Defaults to true. |
| `startedAt` | `number` | Epoch milliseconds. Drives the live elapsed reading. |
| `showElapsed` | `boolean` | Shows the elapsed seconds. Defaults to true. |
| `shortcut` | `boolean` | Binds Escape while running. Defaults to true. |
| `label` | `string` | Names the control. Defaults to "Stop generating". |
## Accessibility
The control disappears rather than dimming when there is nothing to stop, so it is never a button that does nothing. Escape is bound only while running and unbound as soon as it stops. The latest handler is read through a ref, so a changing callback never rebinds the key or leaves a stale one behind. The elapsed reading is decoration beside the name, not the name itself.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Token Meter
How much of the context window is gone, split by what spent it, and a warning before it runs out.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/token-meter
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/token-meter/token-meter.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/token-meter`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/token-meter`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/token-meter`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/token-meter`
Or as a package import:
```ts
import { TokenMeter } from "mischief-ui/token-meter"
```
## Usage
```tsx
export function Usage() {
return (
)
}
```
## Showing where it went
A single number tells someone they are running out; segments tell them what to do about it. Splitting the bar by what spent the budget -- the system prompt, the history, attached files -- turns a warning into a decision, because the largest band is the thing worth dropping.
```tsx
```
Segments are drawn in the order given, and their sum becomes the total.
Order them by how permanent they are, most fixed first, so the part someone can actually reduce ends up at the changing edge of the bar rather than in the middle.
## Running out
Past warnAt -- four fifths of the limit by default -- the reading turns and the component marks itself tight, which you can style against. Lower it when hitting the limit is expensive to recover from, so the warning arrives while there is still room to act on it.
format decides how both numbers read. The default abbreviates, which is right for a window of two hundred thousand; pass your own where exactness matters, or where the budget is money rather than tokens.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `limit` | `number` | The window. Values at or past it read as full. |
| `used` | `number` | Total consumed. Ignored when segments are given. |
| `segments` | `TokenSegment[]` | Named parts that sum to the total, each with its own colour. |
| `warnAt` | `number` | Fraction of the limit that reads as tight. Defaults to 0.8. |
| `format` | `(value: number) => string` | Formats both numbers. Defaults to a compact form. |
| `label` | `string` | Names the meter. Defaults to "Context used". |
| `showLegend` | `boolean` | Shows the segment key. Defaults to true. |
### TokenSegment
| Prop | Type | Description |
| --- | --- | --- |
| `label` | `string` | Named in the key beneath the bar. |
| `value` | `number` | Counted towards the total. |
| `color` | `string` | Any CSS colour. A theme shade is used when omitted. |
## Accessibility
The bar carries meter semantics with its real minimum, maximum, and current value, plus text saying the same thing in words and a percentage, so it is never read by colour alone. Segment colours are repeated in a written key. The bar animates its width and stops doing so under reduced motion.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Model Picker
A model chooser that has room for what each one is good at, and full keyboard control.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/model-picker
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/model-picker/model-picker.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/model-picker`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/model-picker`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/model-picker`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/model-picker`
Or as a package import:
```ts
import { ModelPicker } from "mischief-ui/model-picker"
```
## Usage
```tsx
export function Chooser() {
return (
)
}
```
## Describing the models
The reason to use a listbox rather than a select element is the room it gives you: a sentence about what each model is for, and a few tags for what it can do. Write the description for someone deciding, not for someone who already knows -- speed, depth, and cost are what people are actually choosing between.
```tsx
const models = [
{
id: "opus",
name: "Opus",
description: "The deepest reasoning, for work worth the wait.",
badges: ["reasoning", "vision"],
},
{
id: "haiku",
name: "Haiku",
description: "Quick answers where latency matters more than depth.",
badges: ["fast"],
},
{ id: "legacy", name: "Legacy", disabled: true },
]
```
Keep a retired model in the list with disabled rather than removing it, so a stored preference still resolves to a name instead of falling back to the placeholder. Disabled models are skipped by the arrow keys, not merely dimmed.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `models` | `Model[]` | Each with an id, a name, and optional description, badges, and disabled. |
| `value, defaultValue` | `string, string` | Controlled and uncontrolled selection. |
| `onValueChange` | `(id: string) => void` | Called with the chosen model's id. |
| `label` | `string` | Names the control. Defaults to "Model". |
| `placeholder` | `string` | Shown until something is chosen. |
| `disabled` | `boolean` | Disables the trigger. |
### Model
| Prop | Type | Description |
| --- | --- | --- |
| `id` | `string` | What onValueChange reports and value matches. |
| `name` | `string` | Shown on the trigger and in the list. |
| `description` | `string` | A line beneath the name. |
| `badges` | `string[]` | Short capability tags, such as vision or fast. |
| `disabled` | `boolean` | Listed but unchoosable, and skipped by the keyboard. |
## Accessibility
The trigger declares that it opens a listbox and whether it is open, and names the current model. The list takes focus and drives selection through aria-activedescendant, so the active option is announced without focus leaving the list. Arrow keys move, Home and End jump, Enter and Space choose, Escape closes, and focus returns to the trigger either way. Disabled models are skipped by the keyboard rather than merely dimmed. Pointer events outside close it.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Source Card
One retrieved passage: where it came from, what it said, and how well it matched.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/source-card
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/source-card/source-card.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/source-card`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/source-card`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/source-card`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/source-card`
Or as a package import:
```ts
import { SourceCard } from "mischief-ui/source-card"
```
## Usage
```tsx
export function Sources() {
return results.map((result, index) => (
))
}
```
## About that percentage
score is a fraction from 0 to 1, clamped, and shown as a percentage. What it means is entirely your retriever's business: a cosine similarity, a reranker's output, and a BM25 score are three different quantities, and none of them is a probability that the answer is correct.
So show it only where the reader can act on it. A number that always reads between 80 and 90 percent teaches nobody anything, and a confident-looking percentage attached to a bad passage is worse than no number at all. Leave score out and the bar disappears.
## Making a source checkable
The snippet should be the passage the claim actually rests on, not the opening of the document. The whole value of showing sources is that someone can check the claim in a second, and a first paragraph that happens to sit above the relevant text does not let them.
Where there is no url -- an internal document, a chunk from your own store -- the card still works and simply stops being a link. Give it a source in that case, since the host it would otherwise fall back to does not exist.
```tsx
```
## API
| Prop | Type | Description |
| --- | --- | --- |
| `title` | `ReactNode` | The heading. Becomes a link when a url is given. |
| `url` | `string` | Opens in a new tab, with the host shown underneath. |
| `snippet` | `ReactNode` | The retrieved passage. |
| `source` | `ReactNode` | Where it came from. Falls back to the host of the url. |
| `index` | `number` | Position in the result list, shown as a marker. |
| `score` | `number` | Relevance from 0 to 1, shown as a bar and a percentage. |
| `icon, footer` | `ReactNode, ReactNode` | A leading mark, and a row beneath the passage. |
## Accessibility
Each card is an article with a real heading, so a list of them can be navigated by heading. Links say they open in a new tab and carry rel=noreferrer noopener. The relevance bar is decoration with the percentage written beside it, so the score never depends on seeing the bar. A malformed url degrades to no host rather than throwing.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Empty State
What to show when there is nothing yet: what this place is for, and the way to fill it.
- Family: Feedback
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/empty-state
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/empty-state/empty-state.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/empty-state`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/empty-state`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/empty-state`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/empty-state`
Or as a package import:
```ts
import { EmptyState } from "mischief-ui/empty-state"
```
## Usage
```tsx
export function NoFiles() {
return (
}
title="No documents yet"
description="Upload a PDF to get started."
actions={}
/>
)
}
```
## Writing one worth reading
An empty state is the first thing many people see, and it is usually written last. The default -- No data -- tells someone what they can already see and nothing about what to do, which turns a starting point into a dead end.
- Say what is missing in the words of the thing itself: No documents yet, not No results.
- Say why the space is empty, when the reason is not obvious: nothing uploaded yet reads very differently from a filter that matched nothing.
- Offer the one action that fills it, and only one. A choice of three is a menu, not a way forward.
Distinguish the two kinds. Nothing has ever been here is an invitation, and should show someone how to begin. Nothing matched what you asked for is a result, and should offer a way back -- clearing the filter, widening the search -- rather than the same create button.
## Fitting the space
The default has room to breathe, for a page or a large panel that is otherwise blank. Use size="sm" inside a card, a sidebar, or a column where a tall empty box would push the rest of the layout around.
The description is held to a readable measure rather than stretching the full width of whatever contains it, so it stays legible in a wide panel without any work on your part.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `title` | `ReactNode` | The one line saying what is missing. |
| `description` | `ReactNode` | A sentence of context, held to a readable measure. |
| `icon` | `ReactNode` | Placed in a ring above the title. |
| `actions` | `ReactNode` | Controls beneath, such as the way to add the first item. |
| `size` | `"sm" | "md"` | Vertical room. Use sm inside a panel. |
## Accessibility
The icon is decoration the screen reader skips, so the title carries the meaning. The description is capped at a readable measure rather than stretching across a wide panel. Nothing here traps focus or announces itself; it is a static region, and the action inside it is your own control with your own semantics.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Footer Columns
Labelled columns of links, with the column count and the link rendering left to you.
- Family: Blocks
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/footer-columns
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/footer-columns/footer-columns.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/footer-columns`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/footer-columns`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/footer-columns`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/footer-columns`
Or as a package import:
```ts
import { FooterColumns } from "mischief-ui/footer-columns"
```
## Usage
```tsx
export function Links() {
return (
{label}}
/>
)
}
```
## How many columns
One column below the small breakpoint, two above it, and columnCount at the widest. Six groups in a three-column grid is a different footer from six groups in a row, and only you know which one you meant.
The count travels as a custom property rather than a class, because a class assembled from a variable is never generated: Tailwind reads class names as literal text.
```tsx
```
## Who renders the links
Links are plain anchors unless you say otherwise, and one marked external opens in a new tab, carries rel=noreferrer noopener, and says so in its accessible name. renderLink hands each one back instead, which is how a framework's link gets used without this component knowing about it -- along with the styling, which then becomes yours to keep consistent.
FooterLinkItem is that anchor on its own, and FOOTER_LABEL is the class string the group headings wear. Both are exported, so a footer you assemble yourself keeps one link style and one label style instead of a second set that drifts from this one.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `columns` | `FooterColumn[]` | The groups, each with an optional label and its links. |
| `columnCount` | `number` | Columns at the widest size. Defaults to 3. |
| `renderLink` | `(link: FooterLink) => ReactNode` | Renders every link, for your framework's link component. |
| `link (FooterLinkItem)` | `FooterLink` | One link on its own, outside a column. |
| `renderLink (FooterLinkItem)` | `(link: FooterLink) => ReactNode` | The same override, for that single link. |
### FooterColumn
| Prop | Type | Description |
| --- | --- | --- |
| `label` | `ReactNode` | The small uppercase heading. Omit for an unlabelled group. |
| `links` | `FooterLink[]` | The links in the column, in order. |
### FooterLink
| Prop | Type | Description |
| --- | --- | --- |
| `label` | `ReactNode` | The link text. |
| `href` | `string` | Where it goes. |
| `external` | `boolean` | Opens in a new tab, and says so to a screen reader. |
## Accessibility
Each group is a list, so a screen reader announces how many links it holds before reading them. Labels are plain text rather than headings, so a long directory does not litter the page outline. An external link says where it goes in its accessible name, led by a comma because a leading space is dropped when that name is computed.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Footer Row
A wrapping row of links under its own label, set apart by a dashed rule.
- Family: Blocks
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/footer-row
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/footer-row/footer-row.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/footer-row`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/footer-row`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/footer-row`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/footer-row`
Or as a package import:
```ts
import { FooterRow } from "mischief-ui/footer-row"
```
## Usage
```tsx
export function OtherProducts() {
return
}
```
## A row rather than a column
Some footer links are a list rather than a category: sister products, a legal strip, an A to Z index. A column would give each of them a heading they do not need and a height they do not fill.
Nothing is rendered when there are no links, so a row driven by data that happens to be empty leaves no stray rule behind.
```tsx
```
Turn the rule off where the row already sits under one.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `links` | `FooterLink[]` | The links, laid out as a wrapping row. |
| `label` | `ReactNode` | The small uppercase heading. Optional. |
| `renderLink` | `(link: FooterLink) => ReactNode` | Renders every link, as in Footer Columns. |
| `rule` | `boolean` | The dashed rule above. Defaults to true. |
## Accessibility
Nothing else has to be installed for it: the link shapes it takes are the same ones Footer Columns takes, written out here rather than imported, so the two interchange without either depending on the other. The row is a list, so its length is announced before its contents. The label is plain text rather than a heading, and the rule above it is a border rather than a separator element, so neither adds noise to the page outline.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Footer Wordmark
The oversized brand word that closes a page, drawn as texture rather than content.
- Family: Blocks
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/footer-wordmark
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/footer-wordmark/footer-wordmark.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/footer-wordmark`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/footer-wordmark`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/footer-wordmark`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/footer-wordmark`
Or as a package import:
```ts
import { FooterWordmark } from "mischief-ui/footer-wordmark"
```
## Usage
```tsx
export function Close() {
return northstar
}
```
## Texture, not a heading
It is drawn at seven percent of the surrounding text colour and clipped by the edge of the page, which is why it is hidden from assistive technology and unselectable: a screen reader announcing an enormous brand name at the end of every page is noise, and a word that is half off-screen is not something anyone should be selecting.
Keep it to one short word. It scales with the viewport and never wraps, so anything long is cut off rather than reflowed, and the name you want read belongs in the text above it.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `children` | `string` | One short word. It is set never to wrap. |
| `className` | `string` | Classes for the element, to change its size or tint. |
## Accessibility
Hidden from assistive technology and taken out of the selection, because it is a texture rather than a name. The tint is mixed from the surrounding text colour, so it stays faint on a light ground and on a dark one without being restyled.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Empty Row
One line saying a list came back empty, for a table, a list, or a popover.
- Family: Feedback
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/empty-row
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/empty-row/empty-row.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/empty-row`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/empty-row`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/empty-row`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/empty-row`
Or as a package import:
```ts
import { EmptyRow } from "mischief-ui/empty-row"
```
## Usage
```tsx
export function Results({ rows }) {
if (rows.length === 0) {
return No funds match these filters.
}
return
}
```
## The smallest of three
Empty Row is a line inside something. Empty State fills a panel with a title, a sentence, and a way forward. Not Found fills a page. They are separate components rather than sizes of one, because a filtered table wants a line and a blank page wants a heading, and a component that tries to be both is wrong at one end.
Reach for this one where the surrounding thing already explains itself: a table under its own heading, a search popover, a filter pane. There is nothing to introduce, only a result to report.
## Inside a table
A paragraph is not valid inside a table body, and a row that does not span the columns leaves the message wedged under the first one. Pass colSpan and the component renders the row and the cell for you.
```tsx
{rows.length === 0 ? (
Nothing filed yet.
) : (
rows.map((row) => )
)}
```
## API
| Prop | Type | Description |
| --- | --- | --- |
| `children` | `ReactNode` | The line itself. Defaults to "No matches." |
| `colSpan` | `number` | Renders a table row spanning this many columns. |
| `className` | `string` | Classes for the element. |
## Accessibility
A row inside a table is a real table row spanning every column, so the table's shape stays intact and a screen reader reads the message once rather than as a stray cell. Elsewhere it is a paragraph, announced by whatever region already holds the list. It is not a live region: put it in one only if the list can empty while someone is reading it.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Not Found
The page-scale empty state: a status, a heading you can read across a room, and somewhere to go.
- Family: Feedback
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/not-found
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/not-found/not-found.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/not-found`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/not-found`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/not-found`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/not-found`
Or as a package import:
```ts
import { NotFound } from "mischief-ui/not-found"
```
## Usage
```tsx
export default function NotFoundPage() {
return (
Browse the docs}
>
)
}
```
## Saying what happened
A 404 is the one page nobody chose to visit, so it should spend its words on what to do rather than on apology. Say what probably happened -- a renamed page, a stale link -- and offer the one or two places most people actually wanted.
- Name the likely cause; a bare Not Found tells someone only what they already know.
- Offer a way onward that does not require guessing a URL.
- Keep the status as a code above the title rather than as the title itself, so the sentence is the thing read first.
The same shape suits any dead end with a page to itself: a deleted record, an expired invitation, a region you cannot serve. The heading changes, the structure does not.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `title` | `ReactNode` | The line that carries it. The only required prop. |
| `code` | `ReactNode` | The status above the title, such as "404". |
| `description` | `ReactNode` | What likely happened, held to a readable measure. |
| `actions` | `ReactNode` | Where to go instead. |
| `children` | `ReactNode` | Anything more: a search, a list of likely destinations. |
## Accessibility
The title is the page's h1, because on a page whose only content is this, it is the heading. The status code sits above it as plain text rather than as part of the heading, so the sentence is what a screen reader announces first. Nothing here traps focus or announces itself; the controls inside are your own, with your own semantics.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Image Grid
Thumbnails in even cells or masonry columns, with nothing but React behind them.
- Family: Blocks
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/image-grid
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/image-grid/image-grid.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/image-grid`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/image-grid`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/image-grid`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/image-grid`
Or as a package import:
```ts
import { ImageGrid } from "mischief-ui/image-grid"
```
## Usage
```tsx
export function Shots() {
return (
open(image.id)}
/>
)
}
```
## A grid, or a set of buttons
Without onSelect the tiles are plain elements: a grid of pictures is not a set of controls, and making it one puts a stop on every image for anyone moving through the page by keyboard. Give it onSelect and each tile becomes a named button, which is what you want when choosing one opens something.
onSelect hands back the event as well as the image, so you can keep the element that was clicked and return focus to it when whatever you opened closes.
```tsx
const trigger = useRef(null)
{
trigger.current = event.currentTarget
setOpenId(image.id)
}}
/>
```
## Grid or masonry
The grid gives every image the same cell, which suits a set that should be compared. Masonry uses CSS columns and lets each keep its own height, which suits a mixed set where cropping would lose something.
Masonry fills one column top to bottom before starting the next, so the reading order runs down rather than across. Where the order carries meaning, use the grid.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `images` | `GalleryImage[]` | The thumbnails, in order. |
| `layout` | `"grid" | "masonry"` | Even cells, or each image at its own height. Defaults to "grid". |
| `onSelect` | `(image, event) => void` | Makes every tile a button. Without it the grid is not interactive. |
| `renderImage` | `(image: GalleryImage) => ReactNode` | Replaces the img, for a framework's image component. |
| `emptyState` | `ReactNode` | Shown in place of the grid when there is nothing. |
### GalleryImage
| Prop | Type | Description |
| --- | --- | --- |
| `id` | `string` | Unique within the set. |
| `src` | `string` | The image. |
| `alt` | `string` | What it shows. Empty only if it is decorative. |
| `width, height` | `number` | Intrinsic size, to reserve space before it loads. |
| `caption` | `ReactNode` | Shown over the foot of the tile. |
| `description` | `ReactNode` | Longer text, used by Lightbox. |
| `downloadUrl` | `string` | Offers the original, used by Lightbox. |
| `loading` | `"eager" | "lazy"` | Defaults to lazy. Eager for the first row. |
## Accessibility
Tiles are only buttons when choosing one does something, so a decorative grid does not fill the tab order. Each button is named for the image it opens. Captions are rendered as text over the image rather than as its accessible name, so alt still describes the picture. Width and height reserve space before the image arrives, which keeps the grid from reflowing under the reader.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Lightbox
One image at a time, full bleed, with the rest of the set a key away.
- Family: Blocks
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/lightbox
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/lightbox/lightbox.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/lightbox`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/lightbox`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/lightbox`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/lightbox`
Or as a package import:
```ts
import { Lightbox } from "mischief-ui/lightbox"
```
## Usage
```tsx
export function Viewer() {
const [openId, setOpenId] = useState(null)
return (
)
}
```
## It holds nothing
Which image is showing is yours: openId in, onOpenIdChange out, including the null that closes it. That is what lets the same lightbox be opened from a grid, from a table row, or from a link somewhere else on the page, and what lets the open image live in the URL if you want it to.
Moving through the set is the component's job. Left and Right Arrow wrap around the ends, and the controls are hidden entirely when there is only one image, rather than shown doing nothing.
## What Base UI is doing here
The focus trap, the scroll lock, Escape, the backdrop, and returning focus on close all come from Base UI's dialog rather than from anything written here, which is why this is the only part of a gallery that needs it. Image Grid needs nothing but React.
Pass finalFocus so focus returns to the tile that opened the image rather than to the top of the page.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `images` | `GalleryImage[]` | The whole set, so it can move through them. |
| `openId` | `string | null` | The image being shown. Null closes it. |
| `onOpenIdChange` | `(id: string | null) => void` | Called to move, and with null to close. |
| `renderImage` | `(image: GalleryImage) => ReactNode` | Replaces the img. |
| `finalFocus` | `RefObject` | Where focus lands on close, usually the tile that opened it. |
| `closeLabel` | `string` | Names the close control. Defaults to "Close". |
## Accessibility
It takes the same image shape Image Grid takes, written out here rather than imported, so neither has to be installed for the other. A real dialog: focus is trapped while it is open, the page behind it does not scroll, Escape closes it, and focus returns to finalFocus afterwards. The image's alt is the dialog's accessible name, and its position in the set is the description, so a screen reader hears which of how many it is. Arrow keys move; the previous and next controls are absent rather than disabled when there is nowhere to go.
## Dependencies
- @base-ui/react
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Spinner
The smallest way to say something is happening, for a control that is working.
- Family: Feedback
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/spinner
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/spinner/spinner.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/spinner`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/spinner`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/spinner`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/spinner`
Or as a package import:
```ts
import { Spinner } from "mischief-ui/spinner"
```
## Usage
```tsx
export function Save({ saving }) {
return (
)
}
```
## Spinner or skeleton
A spinner says work is under way. A skeleton says content has not arrived and holds its place. Reach for the spinner when something you pressed is working, and for the skeleton when a region is filling in -- a spinner in the middle of an empty page tells someone to wait without telling them what for.
Give it a label only when nothing beside it already says what is happening. Two announcements of the same wait is one too many.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `size` | `number` | Width and height in pixels. Defaults to 16. |
| `label` | `string` | Announced while it turns. Without one it is decoration. |
| `className` | `string` | Classes for the element. Colour comes from currentColor. |
## Accessibility
With a label it is a status region and announces itself once; without one it is hidden from assistive technology, which is right when the text beside it already says what is happening. It draws in currentColor, so it inherits whatever it sits in. Reduced motion stops the turn and leaves the ring, so a control still reads as busy rather than as an unexplained circle.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Shimmering Text
Words with a light moving across them, for the wait between asking and the first token.
- Family: Feedback
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/shimmering-text
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/shimmering-text/shimmering-text.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/shimmering-text`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/shimmering-text`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/shimmering-text`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/shimmering-text`
Or as a package import:
```ts
import { ShimmeringText } from "mischief-ui/shimmering-text"
```
## Usage
```tsx
export function Waiting() {
return Reading the contract…
}
```
## It is still text
The sweep is a background the words are clipped to, not an image of the words. They can be selected, translated, searched and read aloud exactly as if nothing were moving over them.
Where motion is unwelcome the clipping is dropped and the words keep their own colour. That is why the resting state is a colour rather than transparency: text that is transparent without its animation is text that has gone.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `children` | `ReactNode` | The words. |
| `duration` | `number` | Seconds for one pass. Defaults to 2.4. |
| `extent` | `number` | How wide the bright band is, nought to one. Defaults to 0.35. |
| `paused` | `boolean` | Stops the sweep and leaves the words as they are. |
## Accessibility
The words are ordinary text throughout, so nothing about them is conditional on the animation. Under prefers-reduced-motion the sweep is not applied at all and the text renders in the muted foreground colour, which is the state it would have ended in anyway. It carries no live region of its own: what is being waited for belongs in the sentence, not in an announcement that repeats every time the light goes round.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Skeleton
A placeholder the shape of what is coming, so the page does not jump when it arrives.
- Family: Feedback
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/skeleton
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/skeleton/skeleton.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/skeleton`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/skeleton`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/skeleton`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/skeleton`
Or as a package import:
```ts
import { Skeleton } from "mischief-ui/skeleton"
```
## Usage
```tsx
export function Card({ article }) {
if (!article) {
return (
)
}
return
}
```
## Shaped like the thing it stands in for
A skeleton earns its place by taking the room the content will take. One that is the wrong size moves the page twice: once when it appears and again when it is replaced, which is worse than an empty space that fills in.
So give it the height you know: the avatar is a circle of a fixed size, the card is as tall as a card. Where the length is genuinely unknown, lines renders a block of bars with a short last one, which is what a paragraph looks like from across the room.
```tsx
```
## Saying it out loud
The bars are hidden from assistive technology, because a screen reader has nothing to gain from a description of grey rectangles. That means the waiting is invisible unless you say so: mark the region aria-busy while it loads, and the reader is told to wait rather than hearing an empty container.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `lines` | `number` | Render this many bars, the last one short, as text would be. |
| `className` | `string` | The shape: a height, a width, a radius. |
## Accessibility
Hidden from assistive technology, because a shape standing in for content is not content. Put aria-busy on the region that is filling, so the wait is announced once by the thing that knows about it rather than by every bar. The pulse stops under reduced motion.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Status Pill
A dot and a few words: operational, degraded, closed.
- Family: Feedback
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/status-pill
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/status-pill/status-pill.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/status-pill`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/status-pill`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/status-pill`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/status-pill`
Or as a package import:
```ts
import { StatusPill } from "mischief-ui/status-pill"
```
## Usage
```tsx
export function Health({ status }) {
return (
{status.label}
)
}
```
## The words do the work
The dot is decoration and the label is the state. That is not only for colour blindness: a green dot alone says nothing about whether the thing is up, degraded, or simply not open yet, and the reader has to know your palette to guess.
Write the label as the state rather than as a category. All systems operational reads better than Status: OK, and Market closed says more than Idle.
```tsx
All systems operationalDegraded: search is slowMarket closed
```
## API
| Prop | Type | Description |
| --- | --- | --- |
| `children` | `ReactNode` | The words. They carry the state, not the dot. |
| `tone` | `"ok" | "warn" | "down" | "idle"` | The dot colour. Defaults to "ok". |
| `href` | `string` | Makes it a link, for a status page or a health check. |
| `plain` | `boolean` | A quiet line rather than a bordered pill. |
## Accessibility
The dot is hidden from assistive technology and the label is read as ordinary text, so the state never depends on seeing a colour. With an href it becomes a link and inherits link semantics; without one it is a plain span rather than a control, because a status is something to read, not something to press. The tone is exposed as a data attribute for styling and for tests.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Copy Button
Copies a value and says whether it worked, including when the clipboard refuses.
- Family: Controls
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/copy-button
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/copy-button/copy-button.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/copy-button`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/copy-button`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/copy-button`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/copy-button`
Or as a package import:
```ts
import { CopyButton } from "mischief-ui/copy-button"
```
## Usage
```tsx
export function Key({ apiKey }) {
return (
track("key_copied")}>
Copy key
)
}
```
## When the clipboard refuses
navigator.clipboard.writeText rejects more often than it looks: a page served over http, a sandboxed frame, a browser that wants a user gesture it did not see, a permission that was denied. Left unhandled the button does nothing, says nothing, and leaves an unhandled rejection behind.
This one catches it, says so, and tells you through onCopyError. Reach for that when there is somewhere better to put the failure -- a toast, or a field the reader can select from by hand.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `value` | `string` | What lands on the clipboard. |
| `children` | `ReactNode` | Shown beside the icon. Omit for icon only. |
| `label, copiedLabel, errorLabel` | `string` | The three things it can say. |
| `onCopied` | `(value: string) => void` | Called after a copy that worked. |
| `onCopyError` | `(error: unknown) => void` | Called when the clipboard refused. |
## Accessibility
The outcome is announced through a polite live region whether or not the label is visible, so an icon-only button is not silent. With no visible text it takes the current wording as its accessible name, which changes to say what happened. Copying is one press, and nothing about it depends on hovering.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Secret Field
An API key or token: hidden until asked for, copied whole either way.
- Family: Controls
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/secret-field
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/secret-field/secret-field.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/secret-field`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/secret-field`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/secret-field`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/secret-field`
Or as a package import:
```ts
import { SecretField } from "mischief-ui/secret-field"
```
## Usage
```tsx
export function Key({ apiKey }) {
return (
track("key_copied")}
/>
)
}
```
## What stays readable
A fully masked key is hard to tell apart from another fully masked key, which matters the moment someone has more than one. Leaving the prefix and the last few characters visible makes them distinguishable at a glance without giving the secret away -- sk_live_••••1a2b is recognisably not sk_test_••••9f3c.
```tsx
```
The run of dots is capped, so a long token does not stretch the row.
Copying takes the whole value either way. Someone reaching for the copy button has decided already, and making them reveal it first only puts the secret on screen.
## The part this cannot do
Masking is a courtesy to whoever is stood behind the reader. The value is in the page either way, so it is in the DOM, in the memory of the tab, and in anything that screenshots or records it. This component keeps a secret off the screen; it does not keep it out of the browser.
- Send the secret only to someone entitled to it; masking is not authorisation.
- Show a key in full once, at creation, and store only a prefix and a hash.
- Where it must never be shown again, pass revealable={false} and leave copy as the only way to use it.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `value` | `string` | The secret. Copied in full whether or not it is showing. |
| `visiblePrefix, visibleSuffix` | `number, number` | Characters left readable at each end. Default 0 and 4. |
| `masked, defaultMasked` | `boolean, boolean` | Controlled and uncontrolled hiding. |
| `onMaskedChange` | `(masked: boolean) => void` | Called when it is shown or hidden. |
| `revealable` | `boolean` | Drop the reveal control for a value that must never be shown. |
| `copyable` | `boolean` | Show the copy control. Defaults to true. |
| `onCopied` | `(value: string) => void` | Called after a copy that worked. |
| `label` | `string` | Names the thing, in every control. Defaults to "Secret". |
## Accessibility
While hidden, the run of dots is taken out of the accessibility tree and replaced with a spoken state, because a screen reader announcing forty bullets is worse than useless. The reveal control carries aria-pressed, so its state is known without seeing the icon. Copying announces its outcome through a polite live region, and a clipboard that refuses is reported rather than passing as success.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Pagination
Page numbers with gaps where the run is broken, as buttons or as your own links.
- Family: Wayfinding
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/pagination
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/pagination/pagination.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/pagination`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/pagination`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/pagination`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/pagination`
Or as a package import:
```ts
import { Pagination } from "mischief-ui/pagination"
```
## Usage
```tsx
export function Results({ page, pageCount }) {
return (
)
}
```
## Buttons, or addresses
Buttons suit a list whose page lives in component state. Where the page belongs in the URL -- and on a page anyone might share, bookmark, or let a search engine index, it does -- render real links instead. renderLink hands you the page number and the classes, and you decide what an anchor to it looks like.
```tsx
(
{children}
)}
/>
```
Spread the rest: it carries the label and, on the current page, aria-current.
## Where the gaps fall
Ends are always shown, the current page keeps siblingCount neighbours, and everything between collapses to an ellipsis. A gap costs a slot of its own, so a run short enough to draw whole is drawn whole rather than replaced by something no shorter.
paginationRange is exported, so the same numbers can be worked out without rendering anything -- for a summary line, or for a test.
```tsx
paginationRange({ page: 7, pageCount: 20 })
// [1, "gap", 6, 7, 8, "gap", 20]
```
## API
| Prop | Type | Description |
| --- | --- | --- |
| `page` | `number` | The current page, counting from one. |
| `pageCount` | `number` | How many there are. One or fewer renders nothing. |
| `onPageChange` | `(page: number) => void` | Called with the page that was chosen. |
| `siblingCount` | `number` | Pages either side of the current one. Defaults to 1. |
| `boundaryCount` | `number` | Pages kept at each end. Defaults to 1. |
| `renderLink` | `(link: PaginationLink) => ReactNode` | Renders every page as your own link. |
| `label` | `string` | Names the navigation. Defaults to "Pagination". |
## Accessibility
A navigation landmark with a name, so it can be jumped to and told apart from other navigation on the page. Every page is named in full rather than by its digit alone, and the current one carries aria-current so it is announced as where you are rather than as somewhere to go. The ellipsis is decoration and hidden. Previous and next are absent at the ends rather than present and disabled.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Side Panel
A pane that comes in from the side: an inspector, a filter set, a row's detail.
- Family: Wayfinding
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/side-panel
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/side-panel/side-panel.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/side-panel`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/side-panel`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/side-panel`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/side-panel`
Or as a package import:
```ts
import { SidePanel } from "mischief-ui/side-panel"
```
## Usage
```tsx
export function Inspector({ row, onClose }) {
return (
!open && onClose()}
title={row?.name}
description="Everything we hold about this record."
footer={}
>
)
}
```
## What moves and what stays
The header, the toolbar, and the footer are pinned; only the body scrolls. That is the difference between a panel and a long page pushed sideways: the thing you opened it to do stays reachable however far down you read.
Below the medium breakpoint it takes the full width, because a 28rem pane on a phone is a modal with a stripe of unusable backdrop beside it.
## Taking over, or sitting beside
By default it is a modal: focus is trapped, the page behind does not scroll, Escape closes it, and focus returns to whatever opened it. That suits a panel you finish with before carrying on.
For an inspector you work beside -- a list you keep clicking while the panel stays open -- pass modal="trap-focus". The page behind stays scrollable and clickable, and focus is still kept inside the panel so tabbing does not wander off into it. Pair it with hideBackdrop, since a scrim over a page you can still use is a lie.
```tsx
```
## A panel from a panel
Render a Side Panel inside another and it sets itself in from the edge by stackOffset, so the one behind stays visible as a strip rather than disappearing under it. Depth is counted for you: nothing has to be passed down, and a panel three levels in knows where it is.
```tsx
...
```
Each level is a dialog of its own, so Escape closes the top one first.
## Refusing to close
A panel holding a half-finished form should not vanish because someone pressed Escape or clicked past it. closeOnEscape={false} and dismissible={false} take those away, leaving the close control and whatever you put in the footer as the ways out.
Take them away only when there is something to lose. A panel that cannot be dismissed and has no obvious way out is a trap, so keep the close control visible whenever you do.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `open` | `boolean` | Whether it is showing. The state is yours. |
| `onOpenChange` | `(open: boolean) => void` | Called on Escape, on the backdrop, and by the close control. |
| `side` | `"left" | "right"` | Which edge it comes from. Defaults to "right". |
| `title` | `ReactNode` | Names the panel, and the dialog. |
| `description` | `ReactNode` | A line under the title, and the dialog's description. |
| `toolbar` | `ReactNode` | Pinned above the body: filters, a search, tabs. |
| `footer` | `ReactNode` | Pinned below it: the actions that apply or close. |
| `modal` | `boolean | "trap-focus"` | trap-focus keeps the page usable behind it. |
| `dismissible` | `boolean` | Close when the backdrop is pressed. Defaults to true. |
| `closeOnEscape` | `boolean` | Close on Escape. Turn off where there is unsaved work. |
| `hideBackdrop` | `boolean` | Leave the scrim out. |
| `stackOffset` | `string` | How far a panel opened inside another sits from the edge. |
| `duration` | `number` | Milliseconds the slide takes. Defaults to 250. |
| `width` | `string` | Width from the medium breakpoint up. Defaults to "28rem". |
## Accessibility
A real dialog: the title names it, the description is read after that name, focus is trapped and restored, and Escape closes it unless you have said otherwise. The panel slides from its edge and stops sliding under reduced motion, arriving in place instead. The close control is named and is the first thing reached after the heading, so leaving never means hunting.
## Dependencies
- @base-ui/react
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Render Surface
The canvas the other scenes are drawn on. It stays the size of its box, sleeps when nobody is looking at it, and holds still when motion is reduced.
- Family: Scenes
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/render-surface
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/render-surface/render-surface.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/render-surface`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/render-surface`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/render-surface`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/render-surface`
Or as a package import:
```ts
import { RenderSurface } from "mischief-ui/render-surface"
```
## Usage
```tsx
export function Dots() {
return (
makeDots(size)}
draw={({ context, size, state, delta }) => {
context.setTransform(size.dpr, 0, 0, size.dpr, 0, 0)
context.clearRect(0, 0, size.width, size.height)
for (const dot of state) step(context, dot, delta)
}}
/>
)
}
```
## What it refuses to do
A canvas that animates forever is a battery that empties forever. This one stops on its own in three situations, and none of them need anything from the component drawing on it.
- Scrolled out of view. An IntersectionObserver with a 128 pixel margin stops the loop just after the surface leaves the screen and starts it again just before it returns.
- Tab hidden. The loop stops on visibilitychange rather than relying on the browser to throttle it.
- Reduced motion. One frame is painted and no loop is started at all.
That last one is the important one. A reduced motion setting is not a request for a blank rectangle, so the surface still draws -- it draws the scene at rest and leaves it there.
One frame is enough for a scene that has everything it needs at the moment it mounts, and not enough for one waiting on a picture that has not arrived. That is what revision is for: change it when the late thing turns up and the surface paints once more. Sleeping and waking never rebuild the canvas, because resizing a backing store clears it, and a surface that had painted once would be wiped by the act of stopping.
## Time that does not jump
draw receives both time and delta in seconds. Time counts only the frames that were actually drawn, so a scene that was paused for a minute resumes where it stopped rather than skipping a minute forward.
delta is clamped to a fifteenth of a second. Physics integrated against an unclamped delta after a long stall will throw every particle out of the box in a single step, and clamping is cheaper than discovering that on a slow machine.
## When a resize should not rebuild
By default a resize runs setup again, which is what a particle field wants: the count depends on the area. A setup that acquires something scarce should not do this. A browser allows only a handful of WebGL contexts at once, so a renderer rebuilt on every resize will exhaust them during a single drag of the window edge.
```tsx
makeRenderer(canvas, size)}
draw={({ size, state }) => {
if (size.width !== state.width) resizeRenderer(state, size)
state.renderer.render(state.scene, state.camera)
}}
/>
```
With rebuildOnResize off, the canvas is still resized for you. Only setup is skipped.
## Reading the theme
useThemeColors reads custom properties off a mounted element and returns them as plain channels between zero and one, which is the form a shader uniform or a canvas fill wants. It re-reads when the theme changes, so a scene recolours itself when someone switches to dark.
```tsx
const ref = React.useRef(null)
const colors = useThemeColors(ref, ["--primary", "--background"])
// colors["--primary"] is [r, g, b], each 0 to 1
```
The conversion is done by painting one pixel and reading it back, rather than by parsing the value. shadcn themes are written in oklch and often in color-mix, and letting the browser resolve them is the only approach that stays correct as CSS gains more colour spaces.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `setup` | `(args) => TState` | Builds whatever the drawing needs. Runs again after a resize, and after a lost GPU context comes back. |
| `draw` | `(args) => void` | Called once per frame with the state, the size, the seconds elapsed, and the seconds since the last frame. |
| `teardown` | `(state) => void` | Releases anything setup acquired. |
| `contextType` | `"2d" | "webgl" | "none"` | Which context to ask the canvas for. "none" hands you the bare canvas for a library that wants to attach its own renderer. |
| `maxDpr` | `number` | Highest backing store scale. Defaults to 2, because above that the cost climbs faster than the result improves. |
| `rebuildOnResize` | `boolean` | Whether a resize runs setup again. Defaults to true. |
| `revision` | `string | number` | Change it to ask for one more frame. Needed by anything whose content arrives late. |
| `paused` | `boolean` | Stops the loop without unmounting the canvas. |
| `label` | `string` | Announces the canvas as an image with this description. Without one it is hidden from assistive technology as decoration. |
## Accessibility
The canvas is hidden from assistive technology unless you pass a label, because most scenes are decoration and announcing them is noise. With a label it becomes an image with that description. Reduced motion is honoured by the surface itself, so no component drawing on it can forget to.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Aurora Field
A drifting gradient backdrop for a hero or an empty state, built from the colours already in your theme rather than from a palette it brought with it.
- Family: Scenes
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/aurora-field
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/aurora-field/aurora-field.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/aurora-field`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/aurora-field`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/aurora-field`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/aurora-field`
Or as a package import:
```ts
import { AuroraField } from "mischief-ui/aurora-field"
```
## Usage
```tsx
export function Hero() {
return (
Ship the interface you sketched
)
}
```
## Why one colour is the default
Most gradient backdrops ship with a palette, which means they look like the library they came from rather than like your application. This one takes --primary and derives the rest by rotating its hue, so a blue product gets a blue aurora and an orange one gets an orange aurora without being configured.
Pass more colours when you want them. Any entry that is not a custom property is used as a plain CSS colour, and if there are fewer colours than shapes the remainder are derived from the ones you gave.
```tsx
```
## What it costs to run
The field is drawn at a single device pixel per CSS pixel and then blurred by CSS. Blurring in the compositor is far cheaper than blurring in the canvas, and since the result is soft in every direction there is nothing for the extra resolution to show.
It also stops drawing as soon as it scrolls off screen, so a field behind a hero costs nothing once the reader has moved past it.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `colors` | `string[]` | Theme custom properties or CSS colours. Defaults to ["--primary"]. |
| `blobs` | `number` | How many drifting shapes to draw. Defaults to 5. |
| `speed` | `number` | Multiplies the drift. Defaults to 1. |
| `spread` | `number` | How much of the box each shape covers, as a fraction of the longest edge. Defaults to 0.55. |
| `opacity` | `number` | Strength of each shape. Defaults to 0.85. |
| `paused` | `boolean` | Holds the field still. |
| `children` | `ReactNode` | Rendered above the field. The field itself sits behind on its own layer and ignores the pointer. |
## Accessibility
The field is decoration and is hidden from assistive technology. Children are ordinary markup above it, so a heading inside one is read exactly as it would be anywhere else. Under reduced motion the shapes are painted once and stop, which keeps the colour without the drift.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Grain Overlay
Film grain for any positioned box, which incidentally fixes the banding a wide gradient shows on a good monitor.
- Family: Scenes
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/grain-overlay
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/grain-overlay/grain-overlay.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/grain-overlay`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/grain-overlay`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/grain-overlay`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/grain-overlay`
Or as a package import:
```ts
import { GrainOverlay } from "mischief-ui/grain-overlay"
```
## Usage
```tsx
export function Panel() {
return (
Reel one, take four
)
}
```
## The practical reason to use it
A gradient across a wide screen has fewer available steps than it has pixels, so it arrives in visible bands. Adding noise breaks the boundary between one step and the next, and the eye stops finding the edges. This is the same trick print has used for a century, and it is the reason to reach for grain even when you do not want the texture.
It sits above the content and ignores the pointer, so it can be dropped into a card or a hero without changing anything underneath it. The parent needs a positioning context and, usually, overflow hidden.
## Still by default
Animated grain is a full repaint several times a second for an effect most readers will not consciously notice, so it is off unless you ask. When it is on it stops entirely under reduced motion, because a texture that crawls is exactly the kind of movement that setting exists to remove.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `frequency` | `number` | Higher is finer. Around 0.65 reads as film and 0.2 as coarse paper. Defaults to 0.65. |
| `opacity` | `number` | Strength of the grain. Defaults to 0.22. |
| `blend` | `"overlay" | "soft-light" | "multiply" | "screen" | "normal"` | How the grain mixes with what is underneath. Defaults to "overlay". |
| `animated` | `boolean` | Shifts between four grains about eight times a second, the way projected film does. Off by default. |
## Accessibility
Hidden from assistive technology and transparent to the pointer. The animated variant does nothing at all when reduced motion is set.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Spotlight Card
A card that catches a light following the pointer, and can light every card in its grid from the same pointer at once.
- Family: Scenes
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/spotlight-card
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/spotlight-card/spotlight-card.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/spotlight-card`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/spotlight-card`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/spotlight-card`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/spotlight-card`
Or as a package import:
```ts
import { SpotlightCard } from "mischief-ui/spotlight-card"
```
## Usage
```tsx
export function Plans({ plans }) {
return (
{plans.map((plan) => (
{plan.name}
))}
)
}
```
## Nothing re-renders
The pointer position is written to custom properties on the element itself, not to React state. Moving across a grid of twelve of these updates twelve style properties and renders nothing, which is the difference between a smooth grid and a grid that stutters on a laptop.
It also means the card is an ordinary element. Wrap it in a link, put a form in it, or give it your own background, and none of that interferes with the light.
## One lamp over a grid
With followGroup on, a card that receives the pointer writes the position to every sibling spotlight card as well. Each one converts the same page coordinate against its own rectangle, so the light lands where it would if a single lamp were held above the whole grid rather than one lamp per card.
The effect is quiet and worth the trouble: cards near the pointer glow slightly even though the pointer is not on them, which is what a real light does.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `color` | `string` | A theme custom property or a CSS colour for the light. Defaults to "--primary". |
| `size` | `number` | Radius of the light in pixels. Defaults to 320. |
| `followGroup` | `boolean` | Lights every sibling spotlight card from the same pointer, so a grid reads as one surface under one lamp. |
## Accessibility
The light is decoration drawn behind the content and never carries meaning, so nothing is announced. It fades rather than jumps, and that fade is removed under reduced motion. Because the effect is driven by the pointer rather than by a timer, there is nothing moving for a reader who is not moving.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Constellation Field
Drifting points joined by lines when they come close, brightening and swelling around the pointer.
- Family: Scenes
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/constellation-field
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/constellation-field/constellation-field.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/constellation-field`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/constellation-field`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/constellation-field`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/constellation-field`
Or as a package import:
```ts
import { ConstellationField } from "mischief-ui/constellation-field"
```
## Usage
```tsx
export function Backdrop() {
return (
Move your pointer across it
)
}
```
## Density, not count
A fixed number of points looks crowded in a narrow column and empty across a wide hero. Density is given per unit of area instead, so the field is rebuilt with the right number of points whenever the box changes and looks the same at every width.
## Where the time goes
Linking compares every pair of points, so the work grows with the square of the count. At the default density a panel of ordinary size holds a few dozen points and the comparison is not worth optimising. A full page backdrop at high density is a different matter, and the honest fix there is to lower the density rather than to make the loop cleverer.
The pointer is followed on the window rather than on this element, so the field still answers to it while sitting behind a headline that is taking every event itself. Setting pointerRadius to zero removes the reaction altogether.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `density` | `number` | Points per ten thousand square pixels, so the field looks the same after a resize. Defaults to 5. |
| `speed` | `number` | Multiplies the drift. Defaults to 1. |
| `linkDistance` | `number` | Points closer together than this are joined. Defaults to 120. |
| `pointerRadius` | `number` | How far the pointer reaches, in pixels. Zero turns the reaction off. Defaults to 160. |
| `color` | `string` | A theme custom property or a CSS colour for the points and lines. Defaults to "--foreground". |
| `paused` | `boolean` | Holds the field still. |
## Accessibility
Decoration, hidden from assistive technology, and behind its children on its own layer. Under reduced motion the points are painted once where they started and the drift never begins, so the pattern remains without any movement.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Lattice Field
A grid of dots that parts around the pointer and falls apart when pressed, then climbs back into line.
- Family: Scenes
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/lattice-field
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/lattice-field/lattice-field.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/lattice-field`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/lattice-field`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/lattice-field`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/lattice-field`
Or as a package import:
```ts
import { LatticeField } from "mischief-ui/lattice-field"
```
## Usage
```tsx
export function Backdrop() {
return (
Press it
)
}
```
## Nothing is simulated
A dot has no stored position. Where it lands is worked out from its place in the grid, the clock, and the handful of numbers the pointer contributes, so a frame is one draw call over one buffer that was filled when the lattice was built. There is no velocity to integrate and no per-dot state to keep in step.
The fall is ballistic rather than physical: an outward kick that fades with distance from the press, gravity on top of it, and a floor each dot is not allowed past, set a little differently for every dot so the pile is uneven. It costs the same as the resting grid, and letting go is a mix back towards the grid rather than a simulation that has to be unwound.
## The same lattice, drawn twice
Each frame draws the buffer twice. The first pass runs the same shader with the motion turned down and paints in echoColor, so it sits behind the real dots as a version of the grid that has not caught up yet. The depth comes from the two passes disagreeing, not from a second set of dots being tracked.
## Spacing, not count
A lattice is described by the gap between its dots, so it looks the same in a narrow column as across a wide hero and needs no adjusting when the box changes. Past a budget the spacing widens on its own rather than the count climbing, which keeps a full page backdrop from asking the GPU for hundreds of thousands of points.
The pointer is followed on the window rather than on this element, so the lattice still answers to it while sitting behind a headline that is taking every event itself. Setting pointerRadius to zero removes the reaction, and collapseOnClick to false leaves the grid unbreakable.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `spacing` | `number` | Pixels between dots. The lattice is rebuilt to suit whatever box it gets. Defaults to 16. |
| `dotSize` | `number` | Diameter of a dot in pixels. Defaults to 2. |
| `color` | `string` | A theme custom property or a CSS colour for the dots. Defaults to "--foreground". |
| `echoColor` | `string` | The lagging second colour drawn behind. Setting it to the same value as color turns the echo off. Defaults to "--primary". |
| `pointerRadius` | `number` | How far the pointer reaches, in pixels. Zero turns the reaction off. Defaults to 140. |
| `push` | `number` | Furthest a dot is pushed by the pointer, in pixels. Defaults to 26. |
| `gravity` | `number` | Pixels per second squared, once the lattice has been let go. Defaults to 1400. |
| `scatter` | `number` | Pixels per second the dots leave the press at. Defaults to 320. |
| `collapseOnClick` | `boolean` | Whether a press breaks the lattice. Defaults to true. |
| `sway` | `number` | Multiplies the idle wave. Zero holds the lattice perfectly still. Defaults to 1. |
| `paused` | `boolean` | Holds the lattice still. |
## Accessibility
Decoration, hidden from assistive technology, and behind its children on its own layer, so a press meant for it reaches whatever is on top instead. Under reduced motion the lattice is painted once at rest: the wave never starts and the collapse never runs, so the pattern is there without any movement. Nothing is said only by the collapse, which is why there is no keyboard route to it.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Burst
A short burst of pieces for the moment something finally completes. It draws nothing until it is fired and stops as soon as the last piece falls out of the box.
- Family: Scenes
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/burst
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/burst/burst.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/burst`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/burst`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/burst`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/burst`
Or as a package import:
```ts
import { Burst } from "mischief-ui/burst"
```
## Usage
```tsx
export function Invoice() {
const burst = React.useRef(null)
return (
)
}
```
## It costs nothing while it waits
Most celebration components animate a canvas continuously and simply draw nothing most of the time. This one keeps its loop paused until fire is called and pauses it again on the first frame where no piece is left alive, so a page holding one of these is running no animation at all until the moment it matters.
## What happens under reduced motion
fire draws nothing. A burst is pure movement, and a still frame of one is a pile of rectangles that means nothing to anybody.
The announcement still happens. That is the point of the announce prop: the information a burst carries -- this worked -- reaches a reader who is not going to see it, whether they turned motion off or are using a screen reader.
## Where to put it
It covers its nearest positioned ancestor and ignores the pointer, so it belongs inside the region you want the pieces to fall through rather than at the root of the page. Give that ancestor a positioning context, and overflow hidden if you would rather the pieces did not spill.
```tsx
```
## API
| Prop | Type | Description |
| --- | --- | --- |
| `colors` | `string[]` | Theme custom properties or CSS colours. Defaults to ["--primary", "--foreground"]. |
| `count` | `number` | Pieces per burst. Defaults to 60. |
| `velocity` | `number` | Pixels per second the pieces leave at. Defaults to 420. |
| `gravity` | `number` | Pixels per second squared. Defaults to 900. |
| `announce` | `string` | Announced once when a burst is fired. Leave it out when the burst is decorating something already announced elsewhere. |
### BurstHandle
What the ref gives you.
| Prop | Type | Description |
| --- | --- | --- |
| `fire` | `(options?: BurstOptions) => void` | Starts a burst. Without options it comes from the centre of the box. |
### BurstOptions
| Prop | Type | Description |
| --- | --- | --- |
| `x` | `number` | Pixels from the left of the box. |
| `y` | `number` | Pixels from the top of the box. |
| `count` | `number` | Overrides the piece count for this burst. |
## Accessibility
The canvas is decoration and is hidden. The announce prop puts the meaning of the burst into a polite live region, which is what a reader using a screen reader or a reader with motion turned off actually receives. Nothing about the burst is required to operate anything.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Shader Surface
Four shader backdrops -- caustics, metal, plasma and ripple -- each taking its two colours from your theme, so the same surface arrives dark in a dark application and light in a light one.
- Family: Scenes
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/shader-surface
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/shader-surface/shader-surface.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/shader-surface`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/shader-surface`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/shader-surface`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/shader-surface`
Or as a package import:
```ts
import { ShaderSurface } from "mischief-ui/shader-surface"
```
## Usage
```tsx
export function Hero() {
return (
Caustics
)
}
```
## Two colours is the whole palette
Every variant mixes between exactly two colours, both read from the theme. That constraint is what lets one component cover four quite different looks without any of them fighting the application they were installed into. The default pair is --background and --primary, which means the surface already matches the page before it is configured.
Because the colours are read rather than compiled in, switching the application to dark mode recolours the shader on the next frame. There is no second set of values to keep in step.
```tsx
```
## Choosing between them
- caustics: light through moving water. Busy, and best behind very little text.
- metal: slow bands with a sharp highlight. Reads as a material rather than as weather.
- plasma: soft blended cloud. The quietest of the four, the safest behind a paragraph, and the default for that reason.
- ripple: rings leaving the centre. Directional, so it wants something at the middle to have come from.
## When WebGL is not available
If the context or the program cannot be created, the surface draws nothing and the box keeps its ordinary background and children. A shader that will not compile should cost a reader a plain panel, not a broken one.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `variant` | `"caustics" | "metal" | "plasma" | "ripple"` | Which shader to run. Defaults to "plasma", the quietest of the four. |
| `base` | `string` | The colour the surface settles to. Defaults to "--background". |
| `tint` | `string` | The colour the light in it takes. Defaults to "--primary". |
| `speed` | `number` | Multiplies time. Defaults to 1. |
| `scale` | `number` | Size of the pattern. Larger is busier. Defaults to 3. |
| `paused` | `boolean` | Holds the surface still. |
## Accessibility
The canvas is decoration and is hidden from assistive technology. Children sit above it as ordinary markup. Under reduced motion a single frame is drawn and time never advances, so the pattern is there and nothing in it moves.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Displacement Image
Two images crossing by pushing their pixels through the same noise in opposite directions, with the first image also present as ordinary markup for anything that cannot run it.
- Family: Scenes
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/displacement-image
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/displacement-image/displacement-image.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/displacement-image`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/displacement-image`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/displacement-image`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/displacement-image`
Or as a package import:
```ts
import { DisplacementImage } from "mischief-ui/displacement-image"
```
## Usage
```tsx
export function Card() {
return (
)
}
```
## There is always a picture
The first image is rendered as an ordinary img element underneath the canvas. If WebGL is unavailable, if the shader will not compile, or if the second image never loads, the reader sees a normal photograph rather than an empty grey box.
This is also what the reader sees before the textures have finished uploading, which removes the flash of nothing that these effects usually open with.
## Two images, one box
Each image is fitted to the box the way object-fit cover would fit it, using its own aspect ratio measured after it loads. A portrait and a landscape photograph can therefore be crossed against each other without either being stretched.
Images from another origin need to permit it. The textures are requested anonymously, so a host that does not send the right header will refuse to be read and only the fallback will show.
## Not only the pointer
The crossing follows focus as well as the pointer, so putting a link or a button inside one means a keyboard reader gets the same behaviour. When you would rather drive it from something else -- a scroll position, a carousel index -- pass active and the internal handling steps aside.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `from` | `string` | The image shown at rest. |
| `to` | `string` | The image crossed to. |
| `alt` | `string` | Describes the pair. Required, and used for both the fallback image and the canvas. |
| `intensity` | `number` | How far the pixels are pushed during the crossing, as a fraction of the box. Defaults to 0.35. |
| `duration` | `number` | Seconds the crossing takes. Defaults to 0.7. |
| `active` | `boolean` | Drives the crossing yourself. Without it the crossing follows the pointer and focus. |
| `children` | `ReactNode` | Rendered above the image, for a caption or the link that covers it. |
## Accessibility
The alt text describes the pair and is carried by both the fallback image and the canvas, so the picture is announced once whichever one is showing. The crossing responds to focus as well as hover. It is a transition between two images rather than a loop, so there is nothing running for a reader who is not interacting with it.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Scene Hero
A lit three-dimensional object behind a headline, steered by the pointer and coloured by the theme. The one component here that asks for three.
- Family: Scenes
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/scene-hero
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/scene-hero/scene-hero.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/scene-hero`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/scene-hero`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/scene-hero`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/scene-hero`
Or as a package import:
```ts
import { SceneHero } from "mischief-ui/scene-hero"
```
## Usage
```tsx
export function Hero() {
return (
Built in the open
)
}
```
## What it needs installed
This is the only component in the collection that reaches for three, and it is an optional peer like every other heavy dependency here. Nothing else in Mischief pulls it in, and the package root does not export this component, because a barrel holding it would fail to resolve for everyone who had not installed three.
```tsx
npm install mischief-ui three
```
Import it from its own entry: mischief-ui/scene-hero, not the package root.
That is the trade. Around a hundred and fifty kilobytes for anyone who wants a lit object, and nothing at all for everyone else.
## Why this one is allowed to be heavy
Everything else in this family is drawn with CSS, a two dimensional canvas, or a single shader, because those were enough. Real geometry, a metal surface that responds to two lights, and depth that survives being rotated are not things a gradient can imitate, and that is the bar a component has to clear before it may ask for a renderer.
The renderer is built once and survives a resize rather than being rebuilt, since a browser will only hand out a few GPU contexts and dragging a window edge should not spend them all.
## Coloured by the page it is on
The material takes --primary and the fill light takes --foreground, both read from the mounted element and both updated on the next frame when the theme changes. The background stays transparent, so whatever the section behind it is painted with shows through and the object appears to be standing in the page rather than in a window cut into it.
```tsx
```
## API
| Prop | Type | Description |
| --- | --- | --- |
| `shape` | `"torus-knot" | "icosahedron" | "capsule" | "box" | "torus"` | Which object to light. Defaults to "torus-knot". |
| `color` | `string` | Colour of the object. Defaults to "--primary". |
| `rim` | `string` | Colour of the light that rims it. Defaults to "--foreground". |
| `metalness` | `number` | Zero to one. Defaults to 0.55. |
| `roughness` | `number` | Zero to one. Defaults to 0.25. |
| `speed` | `number` | Turns per second at rest. Defaults to 0.12. |
| `sway` | `number` | How far it leans toward the pointer, in radians. Defaults to 0.35. |
| `paused` | `boolean` | Holds the object still. |
## Accessibility
The object is decoration and the canvas is hidden from assistive technology, so a headline placed inside is read exactly as a headline. The lean follows the pointer and returns to centre when it leaves. Under reduced motion one frame is drawn and the object neither turns nor leans.
## Dependencies
- three
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Scroll Scene
Turns the scrolling of a tall element into a number between nought and one, published to a custom property and to a callback, without rendering the page to do it.
- Family: Scenes
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/scroll-scene
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/scroll-scene/scroll-scene.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/scroll-scene`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/scroll-scene`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/scroll-scene`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/scroll-scene`
Or as a package import:
```ts
import { ScrollScene } from "mischief-ui/scroll-scene"
```
## Usage
```tsx
export function Scrubbed() {
const box = React.useRef(null)
return (
{
if (box.current) box.current.style.opacity = String(p)
}}>
Held while the scene goes past
)
}
```
## Why this is not a piece of state
The obvious shape for this component would be a hook returning a number. That number changes on every frame, so every frame would render the tree under it, and a scroll-linked effect built that way stutters on any machine that is also doing something else.
So progress is published twice, and neither way renders anything. It is written to the element as the --scroll-progress custom property, which CSS can use directly, and it is handed to onProgress, which a canvas or a ref can use directly.
```tsx
.parallax {
translate: 0 calc(var(--scroll-progress) * -80px);
opacity: var(--scroll-progress);
}
```
No JavaScript at all on this side. The property is on the scene element, so anything inside it can read it.
## Choosing a range
- cover: nought when the element first appears at the bottom, one when it has completely gone past the top. The longest span, and the usual choice for a backdrop.
- contain: the span during which the element is fully inside the viewport. Right for something that should finish while it is still being looked at.
- enter: nought to one across the arrival alone.
- exit: nought to one across the departure alone.
The loop runs only while the element is on screen, and takes one final reading on the way out so a scene left behind holds an end value rather than whatever it happened to have.
## Motion that is not the page's idea
This keeps working when someone has asked for reduced motion, and that is deliberate. The movement here is the reader's own: it happens because they are scrolling, it stops when they stop, and it reverses when they go back. That is direct manipulation rather than something the page decided to do at them.
What you drive with it is a different matter. If the progress is running an animation that would be uncomfortable, check the preference where you use it rather than expecting this component to guess.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `range` | `"cover" | "contain" | "enter" | "exit"` | Which span of scrolling maps to nought through one. Defaults to "cover". |
| `onProgress` | `(progress: number) => void` | Called on every frame the element is on screen. |
| `sticky` | `boolean` | Pins the children to the viewport while the scene scrolls past them. |
## Accessibility
The scene is an ordinary element and adds nothing to the accessibility tree. Content inside it is normal markup and is reached in normal order, including when sticky is on. Nothing here is required to read the page, so a reader who never scrolls it past has lost nothing.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Reveal
Moves its children in when they arrive on screen. It changes how something arrives and never whether it is there.
- Family: Motion
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/reveal
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/reveal/reveal.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/reveal`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/reveal`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/reveal`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/reveal`
Or as a package import:
```ts
import { Reveal } from "mischief-ui/reveal"
```
## Usage
```tsx
export function Features({ items }) {
return items.map((item, index) => (
))
}
```
## The content is never withheld
Scroll entrances are usually built by rendering nothing until an observer fires. That breaks the page for anyone whose browser did not run the observer, hides the text from anything reading the markup, and leaves a blank column if a script fails.
Here the children are always rendered and always in the document. Only opacity and a small translation are animated, and both are removed outright under reduced motion, where the content is simply there from the first paint.
## Staggering without another component
There is no group wrapper, because a group wrapper would only be multiplying an index by a number. Do that where you have the index.
```tsx
{rows.map((row, index) => (
{row.label}
))}
```
Around 60 to 120 milliseconds per step reads as a sequence. Much more and the last one feels late.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `from` | `"up" | "down" | "left" | "right" | "none"` | Which way the content travels in from. Defaults to "up". |
| `distance` | `number` | Pixels travelled. Defaults to 16. |
| `delay` | `number` | Milliseconds before it starts. An index times a step staggers a list. |
| `duration` | `number` | Milliseconds. Defaults to 600. |
| `threshold` | `number` | How much has to be on screen before it starts. Defaults to 0.15. |
| `repeat` | `boolean` | Plays again whenever it comes back. Off by default. |
## Accessibility
Content is in the document and in normal order from the first paint, so nothing depends on the animation having run. Reduced motion removes the movement and the fade entirely rather than shortening them. Once played it stays played, unless repeat is on.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Split Text
A heading animated one character, word, or line at a time, and still announced as one sentence rather than as a pile of single letters.
- Family: Motion
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/split-text
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/split-text/split-text.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/split-text`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/split-text`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/split-text`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/split-text`
Or as a package import:
```ts
import { SplitText } from "mischief-ui/split-text"
```
## Usage
```tsx
export function Headline() {
return (
Good interfaces deserve a little mischief
)
}
```
## Announced as a sentence
Splitting a heading into one element per letter is what makes this effect possible and is also what usually ruins it. A screen reader handed forty single-letter elements may read forty letters.
So the whole string is set as the label on the element, and every piece inside is hidden from assistive technology. What is announced is the sentence you wrote. What is animated is the letters. Neither knows about the other.
## Which split to use
Character is the showiest and the most expensive: a forty character heading is forty elements each with its own transition. It suits one large heading and does not suit a paragraph.
Word is the one to reach for at body size. Line is for something already broken into lines, and splits on the newlines in the string rather than trying to work out where the browser wrapped it.
Whitespace is never animated, whichever split you choose, so a gap between two words does not fade in and change the measure while the rest arrives.
## Getting the timing right
The total is the stagger times the number of pieces plus the duration of one piece. At the default stagger a forty character heading takes a little over a second and a half, which is about as long as an entrance can be before it stops feeling like an entrance.
```tsx
Long enough to read while it arrives
```
## API
| Prop | Type | Description |
| --- | --- | --- |
| `children` | `string` | The text. A string, because it has to be split. |
| `by` | `"character" | "word" | "line"` | What each piece is. Defaults to "character". |
| `animation` | `"rise" | "fade" | "blur" | "scale"` | How a piece arrives. Defaults to "rise". |
| `stagger` | `number` | Milliseconds between one piece and the next. Defaults to 28. |
| `delay` | `number` | Milliseconds before the first piece. |
| `duration` | `number` | Milliseconds for one piece. Defaults to 620. |
| `trigger` | `"mount" | "view"` | Whether it plays on arrival or once on screen. Defaults to "view". |
| `as` | `"span" | "h1" | "h2" | "h3" | "p"` | The element rendered. Defaults to "span". |
## Accessibility
The full string is the element's label and every piece is hidden, so the text is announced once, as written. Under reduced motion every piece is at rest from the first paint and nothing moves, fades, or blurs. The text is in the document whether or not the animation ever runs.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Marquee
A row that runs on its own, seamlessly, and turns back into an ordinary scrolling row for anyone who asked for less motion.
- Family: Motion
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/marquee
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/marquee/marquee.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/marquee`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/marquee`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/marquee`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/marquee`
Or as a package import:
```ts
import { Marquee } from "mischief-ui/marquee"
```
## Usage
```tsx
export function Logos() {
return (
)
}
```
## Why the children repeat
A loop that travels the width of its content leaves a gap behind it before it wraps. The children are rendered more than once and the track travels exactly one copy's worth, so the moment it resets is the moment the second copy is where the first one was, and there is nothing to see.
Every copy carries its own trailing gap rather than the track spacing them, which is what keeps each copy the same length and the travel exact. Two copies is the least that works; more only helps when the content is much narrower than the box holding it.
The repeats are decoration. Only the first is content, and the rest are hidden from assistive technology, so a screen reader hears the list once rather than as many times as it happens to be drawn.
## What happens with reduced motion
Stopping the animation is not enough on its own: whatever had not arrived yet would simply never arrive, and the content past the edge would be unreachable. So the repeats are dropped, the animation is dropped, and the container becomes a scrolling one.
What is left is an ordinary row that happens to be wider than its box, which is a thing people already know how to use.
## Setting the pace
duration is the seconds one full pass takes, so the same number reads as faster with less content and slower with more. Set it against the content you actually have rather than looking for one value that suits every row.
```tsx
```
Vertical, slower, with more air between the items.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `direction` | `"left" | "right" | "up" | "down"` | Which way the content travels. Defaults to "left". |
| `duration` | `number` | Seconds for one full pass. Defaults to 20. |
| `gap` | `number` | Pixels between the items, and between one repeat and the next. Defaults to 16. |
| `pauseOnHover` | `boolean` | Holds still while the pointer is over it. Off by default. |
| `copies` | `number` | How many times the children repeat. Defaults to 2, and never goes below it. |
| `fade` | `boolean` | Softens both ends so items arrive and leave. Off by default. |
## Accessibility
Only the first copy of the children is content; the repeats carry aria-hidden, so the list is announced once rather than once per copy. Under prefers-reduced-motion the component stops being a marquee altogether: the repeats are removed, the animation is removed, and the container scrolls instead, so nothing is placed out of reach of somebody who turned motion off. pauseOnHover stops the travel for a pointer, and because the reduced-motion path is a scrolling region rather than a moving one, keyboard users reach the content by scrolling it like any other overflow.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Number Ticker
Counts to a number rather than replacing it, in whatever currency or format you asked for.
- Family: Motion
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/number-ticker
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/number-ticker/number-ticker.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/number-ticker`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/number-ticker`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/number-ticker`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/number-ticker`
Or as a package import:
```ts
import { NumberTicker } from "mischief-ui/number-ticker"
```
## Usage
```tsx
export function Stat({ revenue }) {
return (
)
}
```
## What gets read out
The element is labelled with the final value the whole time, and the counting digits are hidden. A reader using a screen reader is told the number, once, rather than being read a blur of intermediate values or catching whatever it happened to be passing through.
It also means the number is correct before the animation starts and correct if it never starts, which is what happens under reduced motion: the value is set straight away.
## Counting from wherever it was
When the value changes again the count starts from what was on screen, not from the original starting point. A figure that updates while someone is looking at it moves from the old number to the new one, which is the only reading of it that means anything.
The digits are tabular, so the width does not jump about while it counts.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `value` | `number` | Where it is counting to. |
| `from` | `number` | Where it counts from the first time. Defaults to 0. |
| `duration` | `number` | Milliseconds. Defaults to 1400. |
| `format` | `Intl.NumberFormatOptions` | Passed straight through, so currency, percent, and compact all work. |
| `locale` | `string` | Passed to Intl.NumberFormat. |
| `startOnView` | `boolean` | Waits until it is on screen before counting. On by default. |
## Accessibility
The final value is the element's label from the first paint, and the animating digits are hidden. Reduced motion sets the number immediately. Nothing here is a live region, so a page of these does not interrupt anybody.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Tilt Card
A card that leans toward the pointer as though it were a physical object lying on the page.
- Family: Scenes
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/tilt-card
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/tilt-card/tilt-card.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/tilt-card`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/tilt-card`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/tilt-card`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/tilt-card`
Or as a package import:
```ts
import { TiltCard } from "mischief-ui/tilt-card"
```
## Usage
```tsx
export function Pass() {
return (
Reykjavik
Seat 4A
)
}
```
## Nine degrees, not thirty
The default lean is small on purpose. A card that swings twenty or thirty degrees stops reading as a card catching the light and starts reading as a card being thrown around, and text on it becomes genuinely harder to read at the far corner.
The lean is written to custom properties on the element rather than to state, so a grid of these renders nothing while the pointer crosses it.
## With a spotlight
Tilt moves the card and a spotlight moves the light on it. Together they are the usual bento card, and each is still useful without the other.
```tsx
Studio
```
## API
| Prop | Type | Description |
| --- | --- | --- |
| `maxTilt` | `number` | Furthest it leans, in degrees. Defaults to 9. |
| `lift` | `number` | How far it comes toward the pointer, in pixels. Defaults to 6. |
| `glare` | `boolean` | Adds a sheen that moves against the lean. |
| `perspective` | `number` | Pixels. Lower is a stronger effect. Defaults to 900. |
## Accessibility
The lean is decoration and nothing is announced. Under reduced motion the card does not lean at all, rather than leaning more slowly. Because the effect is driven entirely by the pointer, nothing moves for a reader who is not moving one, and a keyboard reader gets an ordinary card.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Connection Beam
A line between two elements with something travelling along it, measured from the elements rather than given as coordinates.
- Family: Scenes
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/connection-beam
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/connection-beam/connection-beam.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/connection-beam`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/connection-beam`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/connection-beam`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/connection-beam`
Or as a package import:
```ts
import { ConnectionBeam } from "mischief-ui/connection-beam"
```
## Usage
```tsx
export function Architecture() {
const container = useRef(null)
const retriever = useRef(null)
const model = useRef(null)
return (
)
}
```
## It reads the elements, not a list of points
A diagram drawn from coordinates is right once, on the screen it was drawn for. Give this the two elements instead and the geometry comes from where they actually are, so the beam survives a reflow: resize the window, wrap the boxes onto another line, put the whole thing in a panel that opens, and the line follows.
Both endpoints and the box around them are watched, which covers layout changes that no resize event would report.
The container has to be a positioned element, since the beam is laid over it. Everything else is measured relative to that box, so page scrolling never enters into it.
## Where it attaches
Beams are drawn between the facing edges rather than the centres, so the line meets a box instead of disappearing under it. Which pair of edges depends on how the two are arranged: side by side, it leaves the right and arrives at the left, and stacked, it leaves the bottom and arrives at the top. anchor overrides that when the automatic choice is not the one you meant.
The bow is square to the run, so two elements connected either way round curve the same amount rather than one of them bending the wrong way. A curvature of nought is a straight line, and a negative one bends the other way, which is what separates several beams landing on the same element.
## The part that moves
The line and the travelling part are the same path drawn twice. The moving one is a dash, and the dashes are measured against a normalised path length, so extent is a share of the line rather than a number of pixels that would mean something different on every screen.
Stagger several beams with delay rather than giving them different durations. Different durations drift apart and eventually all arrive at once, which reads as a coincidence rather than a sequence.
```tsx
{sources.map((source, index) => (
))}
```
Three sources into one model, fanned and staggered.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `containerRef` | `RefObject` | The positioned box both endpoints live inside. |
| `fromRef, toRef` | `RefObject` | The two elements. Nothing is drawn until both exist. |
| `curvature` | `number` | Pixels the path bows away from the straight line. Negative bends the other way. Defaults to 60. |
| `anchor` | `"auto" | "horizontal" | "vertical"` | Which edges to use. Auto picks by the longer axis. |
| `inset` | `number` | Clearance left at each end. Defaults to 4. |
| `pathColor, beamColor` | `string` | A theme token, or any CSS colour. Default to --border and --primary. |
| `width` | `number` | Stroke width. Defaults to 2. |
| `duration` | `number` | Seconds for one trip. Defaults to 3. |
| `delay` | `number` | Seconds before the first trip. Defaults to 0. |
| `reverse` | `boolean` | Sends it the other way along the same path. |
| `extent` | `number` | How much of the path the travelling part covers, from nought to one. Defaults to 0.18. |
## Accessibility
The beam is decoration and carries aria-hidden: it illustrates a relationship that the elements it joins should already state, so nothing is lost when it is not rendered. Under prefers-reduced-motion the travelling part is not drawn at all rather than parked mid-path, where it would read as a stray dash nobody put there, and the line it runs along stays exactly as it was. Because the geometry is measured rather than fixed, a page zoomed or reflowed to a narrow width redraws correctly instead of leaving the line pointing somewhere the boxes no longer are.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Cursor Trail
A fading mark behind the pointer, drawn only inside its own box and only while the pointer is in it.
- Family: Scenes
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/cursor-trail
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/cursor-trail/cursor-trail.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/cursor-trail`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/cursor-trail`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/cursor-trail`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/cursor-trail`
Or as a package import:
```ts
import { CursorTrail } from "mischief-ui/cursor-trail"
```
## Usage
```tsx
export function Panel() {
return (
Move the pointer through here
)
}
```
## Inside its box, not on the page
A trail attached to the whole window follows people into forms, over text they are trying to read, and across every other part of the interface. This one covers the element you put it on and nothing else, which makes it something you can use in one place without it becoming the personality of the entire site.
It also stops. The loop runs while the pointer is over the box and for as long afterwards as it takes the last mark to fade, then pauses. A page holding one of these is running no animation until somebody points at it.
## Under reduced motion
No marks are made at all. A trail is nothing but movement, and a frozen one is a row of dots that means nothing.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `color` | `string` | A theme property or CSS colour. Defaults to "--primary". |
| `size` | `number` | Widest the trail gets, in pixels. Defaults to 26. |
| `life` | `number` | Seconds a mark takes to fade. Defaults to 0.7. |
## Accessibility
The canvas is decoration, hidden from assistive technology, and transparent to the pointer, so anything underneath stays clickable. Nothing is drawn under reduced motion. Nothing here carries meaning, so there is nothing to miss.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Metaballs
Blobs that swell into one another as they meet, taking their two colours from your theme.
- Family: Scenes
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/metaballs
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/metaballs/metaballs.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/metaballs`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/metaballs`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/metaballs`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/metaballs`
Or as a package import:
```ts
import { Metaballs } from "mischief-ui/metaballs"
```
## Usage
```tsx
export function Hero() {
return (
Gooey
)
}
```
## How they merge
Each blob contributes a value to every pixel that falls away with the square of the distance to it. The colour changes where the total crosses a threshold. Nothing decides that two blobs are touching: they merge because their contributions add up, the same way two drops of water do.
The count is capped at twelve because every blob is another term evaluated at every pixel, and past a dozen the shape stops being readable long before the frame rate does.
The blobs also spread themselves to the shape of the box. Placed the same distance apart whatever the box looks like, they bunch into the middle third of a wide one and merge into a single mass, which reads as a glow rather than as a field of blobs.
## Making the pointer one of them
With pointer on, wherever the reader is becomes another blob, merging with the others exactly as they merge with each other. It eases toward the pointer rather than being pinned to it, and fades in on arrival, so nothing pops into existence at the edge of the box.
The pointer is followed on the window rather than on this element, which is what lets the field sit behind other content and still answer to a pointer that never touches it. A backdrop that only reacts when nothing is on top of it is a backdrop that never reacts.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `count` | `number` | How many blobs. Up to twelve. Defaults to 7. |
| `base` | `string` | The background. Defaults to "--background". |
| `tint` | `string` | The blobs. Defaults to "--primary". |
| `speed` | `number` | Multiplies the drift. Defaults to 1. |
| `radius` | `number` | Size of each blob, as a fraction of the shorter edge. Defaults to 0.16. |
| `edge` | `number` | How sharply a blob ends. Lower is gooier. Defaults to 0.35. |
| `pointer` | `boolean` | Makes the pointer one of the blobs, so it merges with the rest as it passes through them. |
## Accessibility
Decoration, hidden from assistive technology, behind its children on its own layer. Under reduced motion one frame is drawn and the blobs never move. If WebGL is unavailable the box keeps its ordinary background and its children.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Dither Image
A photograph reduced to two theme colours through an ordered dither, the way a newspaper reduced one to ink and paper.
- Family: Scenes
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/dither-image
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/dither-image/dither-image.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/dither-image`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/dither-image`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/dither-image`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/dither-image`
Or as a package import:
```ts
import { DitherImage } from "mischief-ui/dither-image"
```
## Usage
```tsx
export function Portrait() {
return (
)
}
```
## Ordered, not random
The threshold each pixel is measured against comes from a repeating four by four matrix rather than from a random number. That is what gives the result its woven, printed look instead of the sandy look of noise, and it is also why the picture is stable: the same pixel gets the same threshold on every frame, so nothing crawls.
Because both colours come from the theme, the same photograph arrives as dark ink on pale paper in a light application and the other way round in a dark one.
## There is always a picture
The original is rendered as an ordinary image underneath. If WebGL is missing, if the shader will not compile, or if the picture comes from a host that refuses to let it be read, the reader gets the photograph rather than an empty box.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `src` | `string` | The picture. |
| `alt` | `string` | Describes it. Required, and used by both the fallback and the canvas. |
| `cell` | `number` | Size of one dot in pixels. Larger is coarser. Defaults to 4. |
| `levels` | `number` | How many tones survive. Two is a pure halftone. Defaults to 2. |
| `base` | `string` | The paper. Defaults to "--background". |
| `tint` | `string` | The ink. Defaults to "--foreground". |
| `contrast` | `number` | Applied before the dither. Defaults to 1.15. |
## Accessibility
The alt text is carried by both the fallback image and the canvas, so the picture is described once whichever is showing. Nothing moves, so there is nothing for reduced motion to remove.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# ASCII Image
A photograph redrawn as characters. The grid is worked out once and kept, so each frame is a single copy rather than thousands of letters measured again.
- Family: Scenes
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/ascii-image
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/ascii-image/ascii-image.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/ascii-image`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/ascii-image`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/ascii-image`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/ascii-image`
Or as a package import:
```ts
import { AsciiImage } from "mischief-ui/ascii-image"
```
## Usage
```tsx
export function Portrait() {
return (
)
}
```
## Worked out once
A grid of this size is several thousand characters, each of which has to be measured and drawn. Doing that every frame to produce an identical result would be the most expensive component here by a wide margin.
So the grid is drawn once into a canvas kept aside, and every frame after that is a single copy of it. It is worked out again only when something it is made of changes: the picture arrived, the box was resized, the theme was switched, a prop moved. Everything else costs one copy.
The surface underneath still sleeps whenever it is off screen or its tab is hidden, so a page holding one of these is doing nothing at all while it is out of sight.
## Choosing the characters
The ramp runs from the character used for the darkest part of the picture to the one used for the lightest, and the last entry is usually a space. Shorter ramps are more graphic and longer ones hold more detail.
```tsx
```
Cells are drawn a little taller than they are wide, because that is the shape of a character.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `src` | `string` | The picture. |
| `alt` | `string` | Describes it. Required. |
| `cell` | `number` | Width of one character cell in pixels. Defaults to 8. |
| `ramp` | `string` | The characters, darkest first. Defaults to "@%#*+=-:. ". |
| `color` | `string` | The characters. Defaults to "--foreground". |
| `background` | `string` | Behind them. Defaults to "--background". |
| `contrast` | `number` | Applied before the ramp. Defaults to 1.2. |
## Accessibility
The canvas is announced as an image with your alt text, so the picture is described exactly as an ordinary one would be. Nothing animates at any setting.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Wireframe Globe
A wireframe world with places marked on it, arcs between them, and the same places written out underneath as text.
- Family: Scenes
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/wireframe-globe
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/wireframe-globe/wireframe-globe.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/wireframe-globe`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/wireframe-globe`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/wireframe-globe`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/wireframe-globe`
Or as a package import:
```ts
import { WireframeGlobe } from "mischief-ui/wireframe-globe"
```
## Usage
```tsx
export function Regions() {
return (
)
}
```
## The list is not optional
A globe is usually showing something real: where the regions are, where the customers are, where the incident is. That information cannot live only in a canvas, so the markers are also rendered as a plain list for anything that does not read one.
This is why label is required rather than optional. A marker without a name is a dot on a sphere and there is nothing to say about it.
## What it needs installed
Along with the scene hero, this is one of the two components that ask for three, and it is an optional peer. The package root does not export it, because a barrel holding it would fail to resolve for everyone who had not installed three.
```tsx
npm install mischief-ui three
```
Import it from its own entry: mischief-ui/wireframe-globe, not the package root.
## Turning it
It turns slowly on its own and can be dragged, and a drag carries a little momentum before the steady turn takes over again. Set interactive to false when the globe sits behind something else that should be receiving the pointer.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `markers` | `GlobeMarker[]` | Places to mark. |
| `arcs` | `GlobeArc[]` | Lines drawn between two places. |
| `color` | `string` | The sphere. Defaults to "--border". |
| `accent` | `string` | The markers and arcs. Defaults to "--primary". |
| `speed` | `number` | Turns per second. Defaults to 0.06. |
| `interactive` | `boolean` | Lets the pointer spin it. On by default. |
### GlobeMarker
| Prop | Type | Description |
| --- | --- | --- |
| `lat` | `number` | Degrees north. |
| `lng` | `number` | Degrees east. |
| `label` | `string` | What the place is called. Required, and used in the text list. |
| `id` | `string` | Optional key. |
### GlobeArc
| Prop | Type | Description |
| --- | --- | --- |
| `from` | `{ lat, lng }` | Where the line starts. |
| `to` | `{ lat, lng }` | Where it ends. |
## Accessibility
The canvas is decoration and the markers are a real list, so the places are read out in order whether or not anything renders. Under reduced motion one frame is drawn and the globe neither turns nor drifts, though it can still be dragged, because that movement is the reader's own.
## Dependencies
- three
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Presence Field
An ambient backdrop that carries what the assistant is doing. It changes colour and pace with the state, and settles into each one rather than snapping to it.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/presence-field
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/presence-field/presence-field.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/presence-field`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/presence-field`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/presence-field`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/presence-field`
Or as a package import:
```ts
import { PresenceField } from "mischief-ui/presence-field"
```
## Usage
```tsx
export function Thread({ status }) {
return (
{/* messages */}
)
}
```
## A second channel, never the only one
This is the one rule that matters here. A colour behind a thread is not a status: it cannot be read out, it is invisible to anyone who cannot distinguish the two colours you chose, and it says nothing at all to a reader who has motion turned off.
Put it behind a thread whose state is already written down. The thinking state component says what is happening in words; this says the same thing in the room around it. Take the words away and you have a page that changes colour for no stated reason.
```tsx
{messages}
```
The field decorates the state. It does not report it.
## It settles rather than switches
Both the colour and the pace are eased toward whatever the current state calls for, on every frame, rather than being set when the state changes. A thread that finishes drifts down to rest over about a second instead of cutting to a new colour.
That easing is why the states are not simply four different shaders. There is one field, and the state moves it.
| State | Pace | Colour |
| --- | --- | --- |
| idle | Very slow | The quiet colour |
| thinking | Steady | The active colour |
| streaming | Quickest, and quicker again with activity | The active colour |
| done | Almost still | The active colour, faint |
| error | Unsettled | The fault colour |
## Keeping the middle quiet
The field is brightest at the edges and weakest in the middle, because the middle is where the thread is. Text stays on an almost plain background while the movement happens around it.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `state` | `"idle" | "thinking" | "streaming" | "done" | "error"` | What the assistant is doing. Defaults to "idle". |
| `base` | `string` | The colour it settles to. Defaults to "--background". |
| `active` | `string` | While it is working. Defaults to "--primary". |
| `fault` | `string` | When something went wrong. Defaults to "--destructive". |
| `quiet` | `string` | While it is idle. Defaults to "--muted-foreground". |
| `activity` | `number` | Nought to one, for how much is arriving. Only read while streaming. |
## Accessibility
The canvas is decoration and is hidden from assistive technology, deliberately: the state belongs to the component that states it in words. Under reduced motion one frame is drawn and the field never moves, which is exactly why it must not be the only signal. Children are ordinary markup above it.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Stream Glow
An edge that breathes along a region while tokens land in it, and stops the moment they do.
- Family: Agent UI
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/stream-glow
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/stream-glow/stream-glow.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/stream-glow`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/stream-glow`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/stream-glow`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/stream-glow`
Or as a package import:
```ts
import { StreamGlow } from "mischief-ui/stream-glow"
```
## Usage
```tsx
export function Answer({ streaming, tokensPerSecond }) {
return (
{/* the answer so far */}
)
}
```
## What it is allowed to mean
Only that something is arriving. It cannot say what, or how far through, or whether it went wrong, so it belongs next to a stop control and a written status rather than standing in for either.
It is CSS, not a canvas. There is nothing to draw here that a shadow and an opacity cannot do, and staying in CSS means it costs nothing and inherits the border radius of whatever you put it on.
## Tying it to the throughput
Rate changes both how quickly the edge breathes and how far it reaches, so a fast answer looks fast. Normalise your tokens per second into nought through one before passing it, and keep the value smoothed: a glow driven by a raw per-frame figure flickers.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `active` | `boolean` | Whether anything is arriving. Off by default. |
| `rate` | `number` | Nought to one. Faster arrival breathes faster and reaches further. Defaults to 0.5. |
| `color` | `string` | A theme property or CSS colour. Defaults to "--primary". |
| `spread` | `number` | Thickness of the glow in pixels. Defaults to 22. |
## Accessibility
The glow is a hidden decorative layer and announces nothing. Under reduced motion the breathing stops and a steady edge remains, so the region is still marked. As with any ambient signal, the words next to it are what actually reports the state.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Scrub Bar
The seek control on its own: a slider that happens to be a timeline, announced in minutes and seconds.
- Family: Controls
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/scrub-bar
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/scrub-bar/scrub-bar.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/scrub-bar`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/scrub-bar`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/scrub-bar`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/scrub-bar`
Or as a package import:
```ts
import { ScrubBar } from "mischief-ui/scrub-bar"
```
## Usage
```tsx
export function Player() {
return (
player.seek(seconds)}
/>
)
}
```
## Dragging and arriving
onValueChange fires all the way through a drag, so the time and the bar keep up with the pointer. onCommit fires once, when the drag ends. Seeking a real player on every change of a drag is how a scrub turns into a stutter.
## What has arrived
buffered draws a second, quieter fill behind the played part, so the difference between somewhere you can go and somewhere still downloading is visible before it is clicked.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `duration` | `number` | Length in seconds. |
| `value` | `number` | Position in seconds when controlled. |
| `defaultValue` | `number` | Position when it is not. |
| `onValueChange` | `(seconds: number) => void` | Throughout a drag. |
| `onCommit` | `(seconds: number) => void` | Once, when the drag ends. |
| `step` | `number` | Seconds an arrow key moves. Defaults to 5. |
| `buffered` | `number` | How much has downloaded, in seconds. |
| `label` | `string` | Names the slider. Defaults to "Seek". |
| `disabled` | `boolean` | Turns it off and takes it out of the tab order. |
## Accessibility
It is a real slider: role, minimum, maximum and current value, focusable, with arrow keys moving by step and Home and End going to either end. aria-valuetext reads it as a time -- one minute forty of three minutes thirty-four -- because a screen reader announcing the raw second count of a recording tells a listener nothing they can use. The handle appears on hover and on focus, so it is never a pointer-only affordance.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# OTP Input
A one time code, one box per character, where pasting the whole code into any box fills the rest.
- Family: Controls
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/otp-input
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/otp-input/otp-input.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/otp-input`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/otp-input`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/otp-input`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/otp-input`
Or as a package import:
```ts
import { OtpInput } from "mischief-ui/otp-input"
```
## Usage
```tsx
export function Verify() {
const [code, setCode] = React.useState("")
return (
)
}
```
## Pasting is the normal case
People do not type these codes. They copy the whole thing from a message and paste it, and they paste it into whichever box happens to have focus. So a paste is caught wherever it lands, filtered to the characters the pattern allows, spread across the boxes from that point, and focus is left on the first box still empty.
The first box also carries the one time code autocomplete hint, which is what lets a phone offer the code straight from the message without anyone touching the clipboard at all.
## The keys people actually press
- Backspace on a filled box clears it and stays. On an empty box it clears the one before and moves back, which is what people expect after overshooting.
- Left and right arrows move between boxes without changing anything.
- Typing into a filled box replaces its character rather than being ignored.
Every box is named as a character and its position, so moving between them announces where you are rather than repeating the same label six times.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `length` | `number` | How many boxes. Defaults to 6. |
| `value` | `string` | Controlled value. |
| `defaultValue` | `string` | Uncontrolled starting value. |
| `onChange` | `(value: string) => void` | The code so far. |
| `onComplete` | `(value: string) => void` | Called once the last box is filled. |
| `pattern` | `RegExp` | Which characters are allowed, tested one at a time. Digits by default. |
| `label` | `string` | Names the group and each box. Defaults to "One time code". |
## Accessibility
The boxes are a named group and each is labelled with its position in it. The numeric keyboard is requested when the pattern is digits, and the first box carries the one time code hint so a phone can offer it. The filled state is shown with a border and a small change of scale rather than colour alone, and that scale change is removed under reduced motion.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Tag Input
An input that turns what you typed into a removable tag, and gives the last one back when you press backspace on an empty field.
- Family: Controls
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/tag-input
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/tag-input/tag-input.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/tag-input`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/tag-input`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/tag-input`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/tag-input`
Or as a package import:
```ts
import { TagInput } from "mischief-ui/tag-input"
```
## Usage
```tsx
export function Topics() {
const [tags, setTags] = React.useState(["design"])
return
}
```
## Every change is said
Adding a tag, removing one, and being refused a duplicate are all announced in a polite live region. Without that, a reader using a screen reader presses Enter and hears nothing, which is indistinguishable from the field being broken.
Each remove control is named with the tag it removes rather than being six identical buttons called Remove.
## The habits it expects
- Enter or a comma ends a tag. Whitespace around it is trimmed.
- Backspace on an empty field takes the last tag back, so overshooting is recoverable without reaching for the pointer.
- Leaving the field commits whatever was half typed, rather than throwing it away.
- A duplicate clears the field and says so, instead of silently doing nothing.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `value` | `string[]` | Controlled tags. |
| `defaultValue` | `string[]` | Uncontrolled starting tags. |
| `onChange` | `(tags: string[]) => void` | The tags after a change. |
| `placeholder` | `string` | Shown in the empty field. |
| `separators` | `string[]` | Keys that end a tag, besides Enter. Defaults to [",", "Enter"]. |
| `max` | `number` | Most tags allowed. The field closes once reached. |
| `allowDuplicates` | `boolean` | Whether the same tag may be added twice. Off by default. |
| `label` | `string` | Names the field. Defaults to "Tags". |
## Accessibility
The field is labelled, described by its status region, and every change is announced politely. Remove controls name their own tag. Clicking the surrounding box focuses the field, and the whole control shows a focus ring when anything inside it has focus.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Combobox
A field that narrows a list as you type, and holds one choice or several as removable chips.
- Family: Controls
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/combobox
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/combobox/combobox.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/combobox`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/combobox`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/combobox`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/combobox`
Or as a package import:
```ts
import { Combobox } from "mischief-ui/combobox"
```
## Usage
```tsx
export function Labels() {
const [labels, setLabels] = React.useState(["bug"])
return (
)
}
```
## One choice, or several
The multiple prop decides what the field holds and what it reports. Without it the chosen label sits in the field and picking closes the list. With it each choice becomes a chip and the list stays open, because choosing four labels should not mean opening the same menu four times.
```tsx
{}} />
{}} />
```
id is a string in the first, and an array of them in the second. TypeScript narrows on multiple, so neither call site casts.
Because the narrowing reads the prop itself, a variable passed as multiple leaves both shapes possible and will not compile. Pick the mode at the call site, or render the two branches separately.
## How typing narrows the list
Options are scored against the query and the best matches come first. Lower is better, and an option matching none of these tiers is dropped rather than ranked last.
| Rank | Match |
| --- | --- |
| 0 | The label is exactly the query |
| 1 | The label starts with the query |
| 2 | The label contains the query |
| 3 | The group contains the query |
| 4 | A keyword contains the query |
| 5 | The description contains the query |
Keywords are for the words people actually type that are not in the label: an old name, an abbreviation, the noun rather than the verb. Ties keep the order you passed, so an option list already arranged deliberately stays that way.
```tsx
const labels = [
{ value: "accessibility", label: "Accessibility", group: "Area", keywords: ["a11y"] },
{ value: "registry", label: "Registry", group: "Area" },
]
```
Typing a11y finds the first even though no label says it.
A field showing exactly what was chosen is not treated as a search, so reopening a single-value combobox offers the whole list again instead of the one option already in the field.
## Ranking it yourself
When the built-in tiers do not suit, pass rank and score the options yourself: fuzzy matching, a field the component knows nothing about, recent choices first. Lower is a better match, and false drops one.
```tsx
import { rankComboboxOption } from "mischief-ui/combobox"
option.recent ? -1 : rankComboboxOption(option, query)
}
/>
```
The built-in ranker is exported, so yours can defer to it rather than reproduce it.
## Options from a server
Filtering an array in the browser is right while the whole set is there. Once options come from a search endpoint, two things change: you need to know what was typed, and the component must stop re-ranking what the server already ordered.
```tsx
const [query, setQuery] = useState("")
const [hits, setHits] = useState([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!query) return setHits([])
const controller = new AbortController()
setLoading(true)
searchPeople(query, { signal: controller.signal })
.then(setHits)
.finally(() => setLoading(false))
return () => controller.abort()
}, [query])
```
Debouncing and aborting stay yours: only you know what the endpoint costs.
While loading, the list says it is searching rather than reporting that nothing matched, because an empty list mid-flight is not an answer. It is marked busy at the same time, so a screen reader is told to wait instead of hearing an empty set.
## Headings, and a limit
An option naming a group appears under a heading, and options naming the same group meet under one heading in the order the group first appears. A heading disappears when nothing under it matches, so filtering never leaves an empty section behind.
max caps how many can be held. At the cap the rest go unavailable rather than vanishing, which keeps the list stable and explains why they cannot be picked. What is already chosen stays removable, and the cap is announced when it is reached.
## Adding one that is not there
Pass onCreate and the list offers what was typed as a new option, unless a label already matches it or the cap has been reached. The component reports the text and nothing more: you decide what value it gets, whether it is saved, and whether it is selected.
```tsx
{
const option = { value: slugify(label), label }
setOptions((current) => [...current, option])
setLabels((current) => [...current, option.value])
}}
/>
```
Reporting the text rather than inventing a value keeps ids yours.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `options` | `ComboboxOption[]` | Each with a value and a label, and optional description, group, keywords, and disabled. |
| `multiple` | `boolean` | Holds several choices as chips instead of one, and keeps the list open. |
| `value, defaultValue` | `string | string[]` | Controlled and uncontrolled selection. An array when multiple. |
| `onValueChange` | `(value: string | string[]) => void` | The selection after a change. An array when multiple. |
| `max` | `number` | How many may be chosen. The rest go unavailable once reached. Multiple only. |
| `onQueryChange` | `(query: string) => void` | Called as the query changes, for fetching the options yourself. |
| `loading` | `boolean` | Says options are on their way. |
| `loadingMessage` | `ReactNode` | Shown while loading. Defaults to "Searching". |
| `filter` | `boolean` | Rank and filter here. Turn it off when results arrive already matched. |
| `rank` | `ComboboxRanker` | Score an option against the query yourself. Lower is better, false drops it. |
| `onCreate` | `(label: string) => void` | Offers what was typed as a new option and reports the text. |
| `createLabel` | `(query: string) => ReactNode` | What the create row says. |
| `label` | `string` | Names the field and the list. Defaults to "Options". |
| `placeholder` | `string` | Shown while the field is empty. |
| `emptyMessage` | `(query: string) => ReactNode` | Shown when nothing matches. |
| `disabled` | `boolean` | Disables the field and every chip control. |
### ComboboxOption
| Prop | Type | Description |
| --- | --- | --- |
| `value` | `string` | What onValueChange reports and value matches. |
| `label` | `string` | Shown in the list, in the chip, and in the field. |
| `description` | `string` | A line beneath the label. |
| `group` | `string` | Puts the option under a named heading. |
| `keywords` | `string[]` | Extra words that should match, without being shown. |
| `disabled` | `boolean` | Listed but unchoosable, and skipped by the keyboard. |
## Accessibility
The field is a combobox that owns the list, says whether it is open, and points at the active option with aria-activedescendant, so focus never leaves the input and nothing is lost between the query and the list. Arrow keys move and step past anything unavailable, Home and End jump, Enter chooses, Escape closes and then clears what was typed, and backspace on an empty field takes the last chip back. The list is marked multi-selectable when it is, busy while options are loading, and its headings name their groups. Every chip control names the option it removes rather than being a row of identical buttons, and additions, removals, and reaching the cap are announced in a polite live region.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Sortable List
A list reordered by dragging a handle, or from the keyboard without one, where every move is announced.
- Family: Controls
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/sortable-list
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/sortable-list/sortable-list.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/sortable-list`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/sortable-list`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/sortable-list`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/sortable-list`
Or as a package import:
```ts
import { SortableList } from "mischief-ui/sortable-list"
```
## Usage
```tsx
export function Tasks() {
const [tasks, setTasks] = React.useState(initial)
return (
task.id}
getLabel={(task) => task.name}
onReorder={setTasks}
renderItem={(task) =>
{task.name}
}
/>
)
}
```
## Reordering without a pointer
Drag and drop is the version of this everyone builds and the version a keyboard cannot use. So the handle is a real button that can be focused, and the whole operation works from there.
- Space or Enter lifts the item, and says so along with what to do next.
- Up and down arrows move it while it is lifted, announcing its new position each time.
- Space or Enter drops it.
- Escape puts the list back the way it was before the lift.
A lifted item is marked as pressed and ringed, so its state is visible as well as announced.
## Saying where things went
Every move reports the item and its new position out of the total. A list that rearranges itself in silence is unusable to anyone not watching it, and that includes anyone who dragged something and looked away.
```tsx
getLabel={(task) => task.name}
```
Without this the announcements can only say Item 3, which is true and useless.
## It owns no order of its own
The list is whatever you passed and every change comes back through onReorder, including the ones made while a drag is still in progress. There is no internal copy to fall out of step with yours, and persisting the order is a matter of saving what you were handed.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `items` | `TItem[]` | The list, in its current order. |
| `getKey` | `(item) => string` | A stable key for each item. |
| `onReorder` | `(items) => void` | The list in its new order. |
| `renderItem` | `(item, index) => ReactNode` | What each row shows. |
| `getLabel` | `(item) => string` | Names the item on its handle and in announcements. Worth passing. |
| `label` | `string` | Names the list. Defaults to "Sortable list". |
## Accessibility
An ordered list whose handles are named buttons carrying the item's name. The full operation is available from the keyboard, with lifted state exposed as pressed, and every move and cancellation announced politely. Escape restores the order from before the lift.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Resizable Panels
Two panels and something to drag between them, where the divider is a real separator that also works from the keyboard.
- Family: Blocks
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/resizable-panels
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/resizable-panels/resizable-panels.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/resizable-panels`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/resizable-panels`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/resizable-panels`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/resizable-panels`
Or as a package import:
```ts
import { ResizablePanels } from "mischief-ui/resizable-panels"
```
## Usage
```tsx
export function Workspace() {
return (
}
second={}
className="h-96 rounded-xl border"
/>
)
}
```
## A separator with a value
The divider is a separator with a current value, a minimum, and a maximum, and it can be focused. That is what makes the split adjustable by anyone who cannot drag: arrow keys move it a step at a time, and Home and End take it to either limit.
A one pixel line is also close to impossible to hit with a pointer, so the area that responds is considerably wider than the line that is drawn. The line stays thin and the target does not.
## Panels that cannot be lost
The size is clamped between the minimum and maximum on every change, wherever it came from, so neither panel can be dragged down to nothing and become impossible to get back. Both panels scroll their own contents rather than pushing the split around.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `first` | `ReactNode` | The panel the size applies to. |
| `second` | `ReactNode` | The panel that takes the rest. |
| `direction` | `"horizontal" | "vertical"` | Which way they sit. Defaults to "horizontal". |
| `size` | `number` | Controlled percentage for the first panel. |
| `defaultSize` | `number` | Uncontrolled starting percentage. Defaults to 50. |
| `onSizeChange` | `(size: number) => void` | The new percentage. |
| `min` | `number` | Smallest percentage. Defaults to 15. |
| `max` | `number` | Largest percentage. Defaults to 85. |
| `step` | `number` | Percentage points an arrow key moves. Defaults to 4. |
## Accessibility
The divider is a separator with an orientation, a current value, and its limits, reachable in the tab order and driven by the arrow keys, Home, and End. Its grab area is far larger than the line it draws. Both panels are ordinary regions and their contents are reached in the order they are written.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Stepper
Where someone is in something with a beginning and an end, said in words as well as drawn.
- Family: Controls
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/stepper
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/stepper/stepper.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/stepper`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/stepper`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/stepper`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/stepper`
Or as a package import:
```ts
import { Stepper } from "mischief-ui/stepper"
```
## Usage
```tsx
export function Setup({ step }) {
return (
)
}
```
## Progress that does not live in a colour
A filled circle means finished and an outlined one means not started, and neither of those is available to a reader who cannot see them. So each step also carries its state as text: finished, in progress, or not started, read after its name.
The current step is marked as the current step in the page, which is how assistive technology finds it without being told where to look.
## Going back only when there is somewhere to go
Pass onSelect and finished steps become buttons that return to them. Leave it out and nothing in the stepper is interactive, which is the right default: most steppers report progress rather than offering navigation, and a control that looks pressable and is not is worse than no control.
Steps ahead of the current one are never reachable, whether or not onSelect is passed.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `steps` | `Step[]` | The steps, in order. |
| `current` | `number` | Index of the step being worked on. |
| `orientation` | `"horizontal" | "vertical"` | Which way it runs. Defaults to "horizontal". |
| `onSelect` | `(index, step) => void` | Makes finished steps revisitable. Without it they are not. |
| `label` | `string` | Names the navigation. Defaults to "Progress". |
### Step
| Prop | Type | Description |
| --- | --- | --- |
| `id` | `string` | Identifies the step. |
| `label` | `string` | What it is called. |
| `description` | `string` | An optional line underneath. |
## Accessibility
A navigation landmark holding an ordered list. Each step's state is written out after its name, the current step is marked as current, and the connecting lines are hidden. Revisitable steps are real buttons, named with where they go, and steps ahead are never among them.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Avatar Stack
Overlapping faces with a count for the rest, and the names underneath as a real list.
- Family: Blocks
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/avatar-stack
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/avatar-stack/avatar-stack.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/avatar-stack`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/avatar-stack`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/avatar-stack`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/avatar-stack`
Or as a package import:
```ts
import { AvatarStack } from "mischief-ui/avatar-stack"
```
## Usage
```tsx
export function Editors({ people }) {
return
}
```
## Faces on top of a list
The stack is a named list and each face is an item in it, so the group is read as the people it contains rather than as a row of pictures. Someone without a picture gets their initials drawn, with their full name still carried underneath, because initials read aloud are not a name.
The overflow count says how many more there are in words as well as showing a number, so it is announced as a quantity of people rather than as a plus sign and a digit.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `people` | `Person[]` | Everyone, not only the ones shown. |
| `max` | `number` | How many faces before the rest become a count. Defaults to 4. |
| `size` | `number` | Pixels across. Defaults to 32. |
| `spread` | `boolean` | Fans the stack out under the pointer. On by default. |
| `label` | `string` | Names the group. Defaults to "People". |
### Person
| Prop | Type | Description |
| --- | --- | --- |
| `name` | `string` | Used as the picture's alt text, or as initials. |
| `src` | `string` | Optional picture. Without one, initials are drawn. |
| `id` | `string` | Optional key. |
## Accessibility
A named list whose items carry full names, whether shown as a picture or as initials. The overflow is announced as a number of further people. Under reduced motion the stack does not fan out, and nothing about the group depends on it having done so.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Timeline
Things that happened, in the order they happened, with the state of each one said aloud rather than left in a coloured dot.
- Family: Blocks
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/timeline
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/timeline/timeline.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/timeline`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/timeline`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/timeline`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/timeline`
Or as a package import:
```ts
import { Timeline } from "mischief-ui/timeline"
```
## Usage
```tsx
export function History({ events }) {
return
}
```
## The dot is not the only thing carrying it
Each tone is drawn as a colour and also written out after the title: finished, happening now, not started, or went wrong. The colour is the quick version for anyone who can see it and the words are the actual record.
Time is taken already formatted rather than as a date, because how a time should be written depends on the locale, the timezone, and whether it is worth showing a year -- none of which a timeline component can work out on your behalf.
## API
| Prop | Type | Description |
| --- | --- | --- |
| `entries` | `TimelineEntry[]` | The events, oldest first. |
| `label` | `string` | Names the list. Defaults to "Timeline". |
### TimelineEntry
| Prop | Type | Description |
| --- | --- | --- |
| `id` | `string` | Identifies the entry. |
| `title` | `string` | What happened. |
| `time` | `string` | When, already formatted. |
| `description` | `ReactNode` | Anything further. |
| `tone` | `"done" | "active" | "todo" | "problem"` | Its state. Defaults to "done". |
## Accessibility
A named ordered list, so the sequence is conveyed as a sequence rather than as a column of text. Each entry's state is announced after its title, and the dots and connecting line are hidden. Nothing animates.
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed
---
# Data Table
Typed rows with cells you write, column widths you set or the reader drags, sorting that is one property to switch on, and selection kept in keys rather than positions.
- Family: Blocks
- Kind: component
- Page: https://ui.tinkererslabs.com/docs/components/data-table
- Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/data-table/data-table.tsx
## Install
npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/data-table`
pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/data-table`
yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/data-table`
bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/data-table`
Or as a package import:
```ts
import { DataTable } from "mischief-ui/data-table"
```
## Usage
```tsx
const columns: Column[] = [
{ key: "name", header: "Name", sort: true },
{ key: "email", header: "Email", cell: (p) => {p.email} },
{ key: "seats", header: "Seats", width: "6rem", align: "end", sort: true },
]
export function People({ people }) {
return (
person.id}
label="People"
/>
)
}
```
## Three levels of effort
A column with no sort property is not sortable, and its heading is plain text rather than a button that looks pressable and does nothing.
sort: true uses the built-in comparator against the column's value, which is the field named by key unless you gave it one. Numbers compare as numbers, dates as dates, and everything else by the reader's locale, so ten does not sort before nine and Ä does not sort after Z.
```tsx
{ key: "seats", header: "Seats", sort: true }
{ key: "plan", header: "Plan", sort: (a, b) => RANK[a.plan] - RANK[b.plan] }
```
Anything with an order that is not alphabetical brings its own comparator.
Pressing a heading a third time clears the sort and returns the rows to the order they arrived in, which is often the order that meant something before anyone touched it.
Empty cells sit at the bottom whichever way the column is pointing. A column of blanks at the top is never what was being asked for.
## Widths, and dragging them
The table is laid out with fixed columns and a colgroup, so a width is any CSS length you like. Columns without one share whatever is left over in equal parts, which is the behaviour you would reach for a fraction unit to get.
```tsx
{ key: "plan", header: "Plan", width: "8rem" }
{ key: "name", header: "Name" } // takes a share of the rest
```
With resizable on, every boundary between two columns can be dragged. The last column has no handle, because there is nothing to its right to trade width with. The first drag pins every column to the width it already had, so pulling one boundary does not make the others jump about, and a drag writes straight to the colgroup rather than into state, so moving a boundary renders nothing.
Each handle is a separator that can be focused and moved with the arrow keys, because a table whose columns can only be adjusted by dragging cannot be adjusted by everybody. Double-clicking a handle returns that column to the width you declared, and no column can be dragged below its minimum and lost.
## Selection is kept in keys
Passing any of selected, defaultSelected, or onSelectionChange turns selection on. What is stored is whatever getKey returns, never a row position, so sorting the table does not silently change what is selected.
Shift-clicking a checkbox extends from the last one touched, which is what people try first. The heading checkbox selects everything and shows the third, in-between state when only some rows are chosen -- a state that has to be set as a property rather than an attribute, which is why it is easy to leave out.
Clicking a row never selects it. Only the checkbox does. That keeps onRowClick free to mean open this without the two gestures fighting, and keeps a link inside a cell working.
## Holding a column while the rest scrolls
A wide table scrolls sideways, and the column saying which row you are looking at is the first thing to go. Pinning holds it against the left edge. The checkbox column is held with it whenever anything is pinned, because a column of checkboxes that has scrolled away from its rows is worse than no checkboxes at all.
```tsx
{ key: "name", header: "Name", pinned: "start", width: "11rem" }
```
Each held column's distance from the edge is written as a custom property rather than as a class, which is what lets the offsets follow a drag. Widen a held column and the ones after it move with it on the same frame, without anything re-rendering. Resizing and pinning are a pair: it is resizing that makes a table wide enough to need it.
## Waiting, and not flashing while you do
loading fills the body with placeholders shaped like the rows they stand in for: one per column, at the same density, so nothing shifts under the reader when the data lands.
They are held back for a tenth of a second first. Most answers arrive faster than that, and a skeleton that appears and vanishes inside two frames reads as a flicker rather than as progress. The table marks itself busy while it waits, and the placeholders carry no text, so there is nothing for a screen reader to read out of them.
## Totals
A column with a footer gets one, and the table grows a foot only when at least one column has asked for it. The function is handed the rows in the order they are shown, so a total is the sum of what is in front of you.
```tsx
{
key: "seats",
header: "Seats",
align: "end",
sortFirst: "desc",
footer: (rows) => rows.reduce((total, row) => total + row.seats, 0),
}
```
sortFirst earns its place on a number: the first press of Seats nearly always means show me the biggest.
## What it deliberately does not do
There is no pagination, no filtering, and no toolbar in here. Mischief already has pagination, and an empty row for when a filter matches nothing, and they compose better as themselves than they would absorbed into this.
```tsx
```
There is no virtualisation either. It would change how every row is rendered, and a few hundred rows do not need it. Reach for a windowing library when you genuinely have thousands.
Nor is there a menu for hiding columns, because there does not need to be. Columns are an array you own, so hiding one is filtering that array before you hand it over, and the widths, the sorting and the pinning all follow from that with nothing else to keep in step.
```tsx
const shown = columns.filter((column) => visible[column.key])
```
## API
| Prop | Type | Description |
| --- | --- | --- |
| `rows` | `TRow[]` | The data, in whatever order it arrived. |
| `columns` | `Column[]` | One entry per column. |
| `getKey` | `(row) => string` | Identity that survives sorting. Selection is kept in these. |
| `getLabel` | `(row) => string` | Names a row, for the checkbox that selects it. Worth passing. |
| `label` | `string` | Names the table. Becomes its caption. |
| `sort / defaultSort` | `DataTableSort | null` | Which column, and which way. |
| `onSortChange` | `(sort) => void` | Called with the new sort, or null. |
| `selected / defaultSelected` | `string[]` | The keys that are selected. Passing any selection prop turns it on. |
| `onSelectionChange` | `(keys: string[]) => void` | The keys after a change. |
| `resizable` | `boolean` | Lets the reader drag the boundary between columns. |
| `onColumnResize` | `(key, width) => void` | For persisting a width you were given. |
| `density` | `"comfortable" | "compact"` | Row height. Defaults to "comfortable". |
| `striped` | `boolean` | Shades alternate rows. |
| `loading` | `boolean` | Shows placeholder rows shaped like the real ones. |
| `loadingRows` | `number` | How many placeholders. Defaults to 5. |
| `stickyHeader` | `boolean` | Holds the header while the body scrolls. |
| `rowClassName` | `(row, index) => string` | Classes for one row. |
| `onRowClick` | `(row, index) => void` | A pointer convenience. Never the only way to reach what it does. |
| `empty` | `ReactNode` | Shown instead of rows when there are none. |
### Column
| Prop | Type | Description |
| --- | --- | --- |
| `key` | `string` | Identifies the column, and names the field read when there is no cell or value. |
| `header` | `ReactNode` | The heading. |
| `cell` | `(row, index) => ReactNode` | What the cell shows. Defaults to the field named by key. |
| `value` | `(row) => SortValue` | What the column is worth when sorted. Defaults to the field named by key. |
| `width` | `string` | Any CSS width. Columns without one share what is left over. |
| `minWidth` | `number` | Narrowest it may be dragged, in pixels. Defaults to 64. |
| `align` | `"start" | "center" | "end"` | End also sets tabular figures. |
| `sort` | `boolean | ((a, b) => number)` | true for the built-in comparator, or your own. Absent means not sortable. |
| `resizable` | `boolean` | Excludes one column while the rest stay resizable. |
| `maxWidth` | `number` | Widest it may be dragged. Unbounded by default. |
| `pinned` | `"start"` | Holds the column against the left edge while the rest scrolls past. |
| `wrap` | `boolean` | Lets the cell run onto a second line instead of being cut short. |
| `footer` | `ReactNode | ((rows) => ReactNode)` | A summary under the column. The function is given the rows in the order shown. |
| `sortFirst` | `"asc" | "desc"` | Which way the first press sorts. Defaults to "asc". |
## Accessibility
A real table with a caption, column headers scoped to their columns, and aria-sort on any column that can be sorted, so the current order is announced when a heading is reached. Sort controls are buttons with a touch-sized target. Resize handles are separators in the tab order, driven by the arrow keys. Every checkbox is named with the row it selects rather than being a column of boxes called Select, and the number chosen is kept in a polite live region. onRowClick is pointer only and is documented as never being the only route to what it does. While loading it marks itself busy, and the placeholders carry no text for anything to read out.
## Dependencies
- lucide-react
---
Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed