# Mischief UI > Every component's documentation, concatenated. 117 components. # Magnetic Tabs Familiar tabs with a gentle pull toward the pointer. Selection stays clear and keyboard navigation remains immediate. - Family: Controls - Kind: component - Page: https://ui.tinkererslabs.com/docs/components/magnetic-tabs - Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/magnetic-tabs/magnetic-tabs.tsx ## Install npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/magnetic-tabs` pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/magnetic-tabs` yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/magnetic-tabs` bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/magnetic-tabs` Or as a package import: ```ts import { MagneticTabs } from "mischief-ui/magnetic-tabs" ``` ## Usage ```tsx const items = [ { value: "overview", label: "Overview", content:

Ready to go.

}, { value: "activity", label: "Activity", content:

No new activity.

}, ] 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={ ) } ``` ## 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{" "} .

``` Both rfc marks read as the same number; notes takes the next one. It also means the array is the thing to sort. Put the sources in the order you want them listed -- by relevance, or by the order you expect them to be met -- and the marks follow. ## Writing the source list A citation is only useful if it can be checked. Give every source a title that says what it is rather than where it lives, and a snippet holding the sentence the claim actually rests on, so a reader can judge it without leaving the page. Sources without a url still work, and are the right shape for an internal document or a passage retrieved from your own store. Hide the printed list with showSourceList={false} when you are rendering it yourself somewhere else on the page. ## API | Prop | Type | Description | | --- | --- | --- | | `sources` | `CitationSource[]` | Id, title, and optional url and snippet. Order sets the numbering. | | `children` | `ReactNode` | The text, with Citation markers placed inline. | | `showSourceList` | `boolean` | Renders the numbered list below the text. Defaults to true. | | `sourceListLabel` | `ReactNode` | Heading for the source list. | | `id (Citation)` | `string` | Which source this marker points at. | ### CitationSource | Prop | Type | Description | | --- | --- | --- | | `id` | `string` | What a citation mark refers to. | | `title` | `string` | Shown in the list, and as the mark's accessible name. | | `url` | `string` | Makes the entry a link. Optional. | | `snippet` | `string` | The passage the claim rests on. | ## Accessibility Markers are real anchors to their list entry, so they work without hover or a pointer. Each one has an accessible name giving the number and the source title, and the visible digit is hidden from assistive technology to avoid reading it twice. A marker whose id is not in sources renders nothing rather than a dead link. External source links say that they open in a new tab. ## Dependencies - lucide-react --- Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed --- # Bounding Boxes Selectable regions drawn over a page image from normalized coordinates, for showing an agent exactly where an answer came from. - Family: Documents - Kind: component - Page: https://ui.tinkererslabs.com/docs/components/bounding-boxes - Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/bounding-boxes/bounding-boxes.tsx ## Install npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/bounding-boxes` pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/bounding-boxes` yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/bounding-boxes` bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/bounding-boxes` Or as a package import: ```ts import { BoundingBoxes } from "mischief-ui/bounding-boxes" ``` ## Usage ```tsx const boxes = [ { id: "total", label: "Total", x: 0.62, y: 0.71, width: 0.2, height: 0.04 }, ] export function Invoice() { return } ``` ## Coordinate system Boxes are positioned in fractions of the image, not pixels. x and y are the top-left corner, width and height run from there, and every value is between 0 and 1. That is what lets the same box survive the image being resized, zoomed, or rendered at a different density. ```tsx // Bottom-right quarter of the image, whatever size it renders at. const box = { id: "total", x: 0.5, y: 0.5, width: 0.5, height: 0.5 } ``` Values outside the range are clamped rather than rejected, so a box that runs past an edge is drawn to the edge instead of spilling out of the frame. Detection models rarely hand you fractions. Divide by the page dimensions the model reported, not by the dimensions you are displaying at. ```tsx const boxes = predictions.map((prediction) => ({ id: prediction.id, label: prediction.field, x: prediction.left / page.width, y: prediction.top / page.height, width: prediction.width / page.width, height: prediction.height / page.height, })) ``` Converting pixel output from a document model. ## Tones Each box takes a tone, which sets its border and fill. Tone is decoration: the label carries the meaning, so a box never depends on colour to be understood. | Tone | Reads as | | --- | --- | | default | An ordinary extraction. | | accent | Something confirmed, or the field in hand. | | warning | Low confidence, or a value that needs a human. | ## API | Prop | Type | Description | | --- | --- | --- | | `src, alt` | `string` | The page image and its description. | | `boxes` | `BoundingBox[]` | Id, optional label and tone, and x, y, width, height as fractions of the page from 0 to 1. | | `activeId, defaultActiveId` | `string | null` | The selected region, controlled or uncontrolled. | | `onActiveChange` | `(id: string | null) => void` | Runs when a region is selected or cleared. | | `showLabels` | `boolean` | Shows the label tab above each region. | | `renderImage` | `(props) => ReactNode` | Uses a framework image component instead of a plain img. | ### BoundingBox | Prop | Type | Description | | --- | --- | --- | | `id` | `string` | Unique within the set. Drives selection. | | `label` | `string` | Shown on the box, and read as its name. | | `tone` | `"default" | "accent" | "warning"` | Border and fill. Defaults to "default". | | `x, y` | `number` | Top-left corner as a fraction of the image, from 0 to 1. | | `width, height` | `number` | Size as a fraction of the image, from 0 to 1. | ## Accessibility Regions are a labelled list of toggle buttons, so they are reachable by keyboard and announced with their label and pressed state rather than only by colour. The visible label is decorative and hidden from assistive technology to avoid reading it twice. Coordinates are clamped to the page, so bad data cannot push a region off the image or out of the document flow. --- Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed --- # Annotation Layer Notes attached to regions of a page. Drag to add one, select to read it, and the coordinates stay relative to the page rather than the screen. - Family: Documents - Kind: component - Page: https://ui.tinkererslabs.com/docs/components/annotation-layer - Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/annotation-layer/annotation-layer.tsx ## Install npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/annotation-layer` pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/annotation-layer` yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/annotation-layer` bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/annotation-layer` Or as a package import: ```ts import { AnnotationLayer } from "mischief-ui/annotation-layer" ``` ## Usage ```tsx export function Review({ page }: { page: string }) { return ( addNote(rect)} /> ) } ``` ## Coordinate system Annotations are stored as fractions of the image rather than pixels: x and y are the top-left corner, width and height run from there, and everything sits between 0 and 1. A note therefore stays on the same words when the image is resized, zoomed, or rendered on a denser screen, and the same numbers survive being stored and read back at another size. ```tsx // A note over the middle of the page, whatever it renders at. const annotation = { id: "clause-4", x: 0.25, y: 0.4, width: 0.5, height: 0.08, note: "Check this against the master agreement", author: "Aman", } ``` ## Drawing and storing The component holds no list of its own. Dragging on the image calls onCreate with the rectangle, and it is yours to store, give an id, and pass back in. Nothing appears until you do, which is what lets you await a save and show a failure instead of a note that was never kept. ```tsx async function onCreate(rect) { const saved = await api.annotate({ ...rect, note: await ask() }) setAnnotations((current) => [...current, saved]) } ``` readOnly keeps the notes visible and stops new ones being drawn, which is the right mode for anyone without permission to comment. minSize discards a stray click that would leave an annotation too small to find again. ## API | Prop | Type | Description | | --- | --- | --- | | `src, alt` | `string` | The page image and its description. | | `annotations` | `Annotation[]` | Id, note, author, and x, y, width, height as fractions of the page. | | `onCreate` | `(rect: AnnotationRect) => void` | Runs with a new region when someone drags one out. Omit it to disable drawing. | | `onDelete` | `(id: string) => void` | Shows a delete control when given. | | `activeId, defaultActiveId, onActiveChange` | `string | null` | The selected note, controlled or uncontrolled. | | `minSize` | `number` | Smallest drag that counts as a region. Defaults to 0.01 of the page. | ### Annotation | Prop | Type | Description | | --- | --- | --- | | `id` | `string` | Unique within the set. Drives selection. | | `x, y` | `number` | Top-left corner as a fraction of the image, from 0 to 1. | | `width, height` | `number` | Size as a fraction of the image, from 0 to 1. | | `note` | `string` | The comment itself. | | `author` | `string` | Who left it. | ## Accessibility Regions are toggle buttons carrying their note as an accessible name, so notes can be reached and read without a pointer. The note itself appears in a polite live region rather than a hover card. A drag that never moved is treated as a deselect instead of creating an unusably small region. Coordinates are fractions of the page, so they survive zoom and a change of screen. ## Dependencies - lucide-react --- Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed --- # Redaction Mark regions to black out before a document leaves the building, with a reveal that says plainly it is only a preview. - Family: Documents - Kind: component - Page: https://ui.tinkererslabs.com/docs/components/redaction - Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/redaction/redaction.tsx ## Install npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/redaction` pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/redaction` yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/redaction` bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/redaction` Or as a package import: ```ts import { Redaction } from "mischief-ui/redaction" ``` ## Usage ```tsx export function Prepare({ page }: { page: string }) { return ( addRegion(rect)} onDelete={removeRegion} /> ) } ``` ## This hides, it does not remove The black boxes are drawn over an image in the browser. Nothing about the file underneath changes. If you serve the original alongside the regions, everyone still has the unredacted document, and a reader who opens it directly, saves the image, or asks the network tab will see exactly what you meant to hide. Treat this component as the place where a person decides what to hide, and treat the regions it produces as instructions for a server that then does the hiding for real: rasterising the page with the pixels removed, or stripping the text from the source before the file is ever sent. - Never send the original to a client that is only allowed to see the redacted version. - Burn the redaction into the pixels on the server, then delete the original from anything the client can reach. - For a PDF, removing the drawn rectangle is not enough -- the text layer beneath it has to go too, or the words remain selectable. The reveal control exists for the person doing the redacting, so they can check their own work. It is not a permission boundary, and anything it can show was already in the page. ## Regions Regions are fractions of the image, from 0 to 1, and are clamped into that range rather than rejected. That keeps them correct as the image is resized, and it means the same numbers can be handed to a server that renders the page at a completely different scale. ```tsx function onCreate(rect) { setRegions((current) => [ ...current, { id: crypto.randomUUID(), reason: "Bank details", ...rect }, ]) } ``` onCreate hands you the drawn rectangle; you decide what it means and keep it. minSize rejects an accidental click that would otherwise leave an invisible region behind. Give a reason where you can: it is what makes an audit of what was hidden, and why, possible later. ## API | Prop | Type | Description | | --- | --- | --- | | `src, alt` | `string` | The page image and its description. | | `regions` | `RedactionRegion[]` | Id, optional reason, and x, y, width, height as fractions of the page. | | `onCreate` | `(rect: RedactionRect) => void` | Runs with a new region. Omit it to disable drawing. | | `onDelete` | `(id: string) => void` | Removes a region. | | `revealed, defaultRevealed, onRevealedChange` | `boolean` | Whether the covered regions are shown for review. | | `readOnly` | `boolean` | Shows the result without editing controls. | ### RedactionRegion | Prop | Type | Description | | --- | --- | --- | | `id` | `string` | Unique within the set. Used to delete. | | `x, y` | `number` | Top-left corner as a fraction of the image, from 0 to 1. | | `width, height` | `number` | Size as a fraction of the image, from 0 to 1. | | `reason` | `string` | Why this was hidden. Worth recording for an audit. | ## Accessibility Every region carries a number and its reason in text, and says when it is revealed, so the state is never conveyed by a black rectangle alone. Revealing raises a status message stating that the cover is visual only and the source file still has to be redacted, because a component that merely paints over a page must not be mistaken for one that removes data. ## Dependencies - lucide-react --- Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed --- # Page Navigator A rail of page thumbnails for moving through a long document, with arrow-key navigation and a clear active page. - Family: Documents - Kind: component - Page: https://ui.tinkererslabs.com/docs/components/page-navigator - Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/page-navigator/page-navigator.tsx ## Install npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/page-navigator` pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/page-navigator` yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/page-navigator` bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/page-navigator` Or as a package import: ```ts import { PageNavigator } from "mischief-ui/page-navigator" ``` ## Usage ```tsx export function Sidebar({ pages }: { pages: DocumentPage[] }) { return } ``` ## Numbering and thumbnails Pages carry their own number rather than being counted from their position, so a navigator over pages 40 to 60 of a long document says 40 to 60. Whatever you pass is what is shown and what onActivePageChange reports. src is optional. Without it the page still appears, as a numbered placeholder, which is what you want while thumbnails are still being rendered -- the strip keeps its full length instead of growing as images arrive and pushing the current page around. ```tsx ({ number: page.number, src: thumbnails[page.number], label: page.heading, }))} activePage={current} onActivePageChange={setCurrent} /> ``` ## Which way it runs Vertical is the familiar side rail beside a document, and is the better choice for a long file because a tall strip holds more thumbnails at a readable size than a wide one does. Horizontal suits a short document, or a narrow screen where a side rail would take a third of the width. renderImage lets your own image component take over -- a framework's optimised image, a signed URL that needs refreshing, a canvas you are already painting pages onto. ## API | Prop | Type | Description | | --- | --- | --- | | `pages` | `DocumentPage[]` | Page number, optional thumbnail src, and optional label. | | `activePage, defaultActivePage` | `number` | The current page, controlled or uncontrolled. | | `onActivePageChange` | `(page: number) => void` | Runs when the page changes. | | `orientation` | `"vertical" | "horizontal"` | Rail direction. Defaults to "vertical". | | `renderImage` | `(props) => ReactNode` | Uses a framework image component for thumbnails. | ### DocumentPage | Prop | Type | Description | | --- | --- | --- | | `number` | `number` | Shown as the page number, and reported on change. | | `src` | `string` | Thumbnail. Omit for a numbered placeholder. | | `label` | `string` | Extra description, such as a section heading. | ## Accessibility The rail is a tab list with a single tab stop. Arrow keys move between pages along the rail's orientation, Home and End jump to the ends, and focus follows selection. Pages without a thumbnail fall back to an icon and still carry their number, so the control works before any image has loaded. ## Dependencies - lucide-react --- Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed --- # File Tree An expandable tree of folders and files with full keyboard navigation and correct tree semantics. - Family: Documents - Kind: component - Page: https://ui.tinkererslabs.com/docs/components/file-tree - Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/file-tree/file-tree.tsx ## Install npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/file-tree` pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/file-tree` yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/file-tree` bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/file-tree` Or as a package import: ```ts import { FileTree } from "mischief-ui/file-tree" ``` ## Usage ```tsx const nodes = [ { id: "invoices", name: "invoices", kind: "folder", children: [{ id: "jan", name: "january.pdf" }], }, ] export function Files() { return } ``` ## Building the tree Nodes nest through children. A node is treated as a folder when it has a children array -- including an empty one -- so an empty folder is spelled children: [] rather than left out. Set kind explicitly when you want a folder that has not loaded its contents yet to still look like a folder. ```tsx const nodes = [ { id: "app", name: "app", children: [ { id: "app/page.tsx", name: "page.tsx", meta: "2.4 kB" }, { id: "app/api", name: "api", children: [] }, ], }, { id: "README.md", name: "README.md" }, ] ``` Ids must be unique across the whole tree, not just among siblings, because expansion and selection are tracked by id. Paths make good ids for that reason. ## Loading children on demand The component renders the nodes it is given and does not fetch anything. To fill a folder when it opens, control expansion and replace that node's children as the answer arrives. ```tsx const [expandedIds, setExpandedIds] = useState([]) async function onExpandedChange(ids: string[]) { const opened = ids.find((id) => !expandedIds.includes(id)) setExpandedIds(ids) if (opened && !loaded.has(opened)) { setNodes(await withChildren(opened, await listDirectory(opened))) } } ``` Give the folder a spinner in meta while its request is in flight. ## API | Prop | Type | Description | | --- | --- | --- | | `nodes` | `FileTreeNode[]` | Id, name, kind, optional children, meta, and icon. | | `expandedIds, defaultExpandedIds` | `string[]` | Which folders are open, controlled or uncontrolled. | | `selectedId, defaultSelectedId` | `string | null` | The selected node, controlled or uncontrolled. | | `onSelect` | `(node: FileTreeNode) => void` | Runs on selection. | | `onExpandedChange` | `(ids: string[]) => void` | Runs when a folder opens or closes. | ### FileTreeNode | Prop | Type | Description | | --- | --- | --- | | `id` | `string` | Unique across the whole tree. Paths work well. | | `name` | `string` | The label. | | `kind` | `"file" | "folder"` | Overrides the guess made from children. | | `children` | `FileTreeNode[]` | Present, even empty, means a folder. | | `meta` | `ReactNode` | Trailing detail such as a size or a status. | | `icon` | `ReactNode` | Replaces the default file or folder mark. | ## Accessibility The tree uses tree and treeitem roles with aria-level and aria-expanded on every row, so depth and state are announced rather than implied by indentation. There is one tab stop into the tree. Up and Down move between visible rows, Right opens a folder or steps into it, Left closes it or moves to its parent, Home and End jump to the ends, and Enter or Space selects. ## Dependencies - lucide-react --- Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed --- # Document Splits Mark where one scanned batch becomes several documents. Splits are toggled between pages and the segments update as you go. - Family: Documents - Kind: component - Page: https://ui.tinkererslabs.com/docs/components/document-splits - Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/document-splits/document-splits.tsx ## Install npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/document-splits` pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/document-splits` yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/document-splits` bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/document-splits` Or as a package import: ```ts import { DocumentSplits } from "mischief-ui/document-splits" ``` ## Usage ```tsx export function Batch({ pages }: { pages: SplitPage[] }) { return } ``` ## How a split is stored The state is not a list of documents. It is splitAfter: the page numbers that a break falls after. Everything else -- the segments, their order, how many there are -- is derived from that, which is why dragging a break never has to renumber anything. ```tsx // Twelve pages, broken into 1-3, 4-9, and 10-12. ``` A break after the final page is ignored, since it would produce an empty segment. Duplicates and unsorted values are fine; the list is sorted before use. ## Turning it into files What a server needs is ranges, and they fall straight out of the same array. Do the conversion where the split is submitted rather than storing both, so there is only ever one description of where the breaks are. ```tsx function toRanges(pages, splitAfter) { const bounds = [...new Set(splitAfter)].sort((a, b) => a - b) const last = pages.at(-1).number const starts = [pages[0].number, ...bounds.map((page) => page + 1)] return starts .filter((start) => start <= last) .map((start, index) => ({ start, end: bounds[index] ?? last })) } ``` Gives [{ start: 1, end: 3 }, { start: 4, end: 9 }, { start: 10, end: 12 }]. ## API | Prop | Type | Description | | --- | --- | --- | | `pages` | `SplitPage[]` | Page number, optional thumbnail src, and optional label. | | `splitAfter, defaultSplitAfter` | `number[]` | Page numbers a split follows, controlled or uncontrolled. | | `onSplitChange` | `(splitAfter: number[]) => void` | Runs with the sorted boundaries whenever they change. | | `segmentLabel` | `(segment: DocumentSegment) => ReactNode` | Replaces the default heading above each document. | | `renderImage` | `(props) => ReactNode` | Uses a framework image component for thumbnails. | ### SplitPage | Prop | Type | Description | | --- | --- | --- | | `number` | `number` | The page number. What splitAfter refers to. | | `src` | `string` | Thumbnail. Omit for a numbered placeholder. | | `label` | `string` | Extra description for the page. | ### DocumentSegment | Prop | Type | Description | | --- | --- | --- | | `index` | `number` | Position of the segment, from zero. | | `pages` | `SplitPage[]` | The pages it contains, in order. | ## Accessibility Each split control is a toggle button naming the page it follows, so the action is clear without seeing the layout. No control is offered after the final page, since a split there would mean nothing. Segment headings state the document number and page count as text. ## Dependencies - lucide-react --- Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed --- # Schema Builder Build the shape you want extracted from a document. Fields carry a name, type, description, and requirement, and object and array fields nest. - Family: Documents - Kind: component - Page: https://ui.tinkererslabs.com/docs/components/schema-builder - Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/schema-builder/schema-builder.tsx ## Install npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/schema-builder` pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/schema-builder` yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/schema-builder` bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/schema-builder` Or as a package import: ```ts import { SchemaBuilder } from "mischief-ui/schema-builder" ``` ## Usage ```tsx export function Extraction() { return ( ) } ``` ## The shape it produces Fields come back as a tree, in the order they were arranged. Object and array fields nest through their own fields array, and everything else is a leaf. This is deliberately not JSON Schema: it is small enough to read, and short enough to convert into whatever your extractor actually wants. ```tsx const fields = [ { id: "1", name: "total", type: "number", required: true }, { id: "2", name: "supplier", type: "object", fields: [ { id: "3", name: "name", type: "string", required: true }, { id: "4", name: "vat", type: "string" }, ], }, ] ``` Six types are offered by default -- string, number, boolean, date, object, and array. Narrow that with types when your extractor supports fewer, and cap nesting with maxDepth so nobody builds a structure the other end cannot represent. ## Ids for new fields New fields need an id, and the default generator is fine for a form whose result is read once. Pass createId when the ids are going to outlive the page -- stored, compared, or sent somewhere that expects them to be stable. ```tsx crypto.randomUUID()} onFieldsChange={save} /> ``` crypto.randomUUID is available in the browser and on modern Node. ## API | Prop | Type | Description | | --- | --- | --- | | `fields, defaultFields` | `SchemaField[]` | The schema, controlled or uncontrolled. | | `onFieldsChange` | `(fields: SchemaField[]) => void` | Runs on every edit. | | `types` | `readonly SchemaFieldType[]` | The type options offered. Defaults to string, number, boolean, date, object, and array. | | `maxDepth` | `number` | How far object and array fields may nest. Defaults to 3. | | `createId` | `() => string` | Supplies ids for new fields when you need them stable. | ### SchemaField | Prop | Type | Description | | --- | --- | --- | | `id` | `string` | Unique across the whole tree. | | `name` | `string` | The field name as it will be extracted. | | `type` | `SchemaFieldType` | string, number, boolean, date, object, or array. | | `description` | `string` | A hint for whoever, or whatever, fills it. | | `required` | `boolean` | Marks the field as expected. | | `fields` | `SchemaField[]` | Children, on an object or an array. | ## Accessibility Every input has a label naming the field it belongs to, so a screen reader user knows which row they are editing rather than hearing a run of unlabelled boxes. Nesting controls say which field they open, and remove buttons name the field they delete. Only object and array fields offer nesting, and nesting stops at maxDepth. ## Dependencies - lucide-react --- Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed --- # Signature Pad Sign with a pointer on a canvas, or type a name instead. Returns a PNG data URL or the typed text. - Family: Documents - Kind: component - Page: https://ui.tinkererslabs.com/docs/components/signature-pad - Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/signature-pad/signature-pad.tsx ## Install npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/signature-pad` pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/signature-pad` yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/signature-pad` bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/signature-pad` Or as a package import: ```ts import { SignaturePad } from "mischief-ui/signature-pad" ``` ## Usage ```tsx export function Sign() { return setSignature(value)} /> } ``` ## What you get back onChange reports the whole signature or null when it is cleared. A drawn signature arrives as a PNG data URL; a typed one arrives as the text, leaving the rendering to you. The mode tells you which of the two you are holding, so you never have to guess from which field is set. ```tsx { if (!value) return clear() if (value.mode === "draw") return save({ image: value.dataUrl }) save({ typed: value.text }) }} /> ``` Store the typed variant as text rather than as a picture of text. It stays searchable, it survives a font change, and you can render it at whatever size the document needs. ## Sharpness The canvas is sized to the device pixel ratio and the drawing context scaled to match, so strokes are sharp on a retina screen instead of soft. That also means the exported PNG comes out at the device's resolution, not at the CSS size: on a 2x screen a 480 by 180 pad exports 960 by 360. Size for that if the image is going into a printed document, and remember the export carries whatever penColor and lineWidth were set, on a transparent background. ## API | Prop | Type | Description | | --- | --- | --- | | `mode, defaultMode` | `"draw" | "type"` | The active method, controlled or uncontrolled. | | `onChange` | `(value: SignatureValue | null) => void` | Runs with the PNG data URL or typed text, and null once cleared. | | `penColor, lineWidth` | `string, number` | Stroke appearance. | | `height` | `number` | Signing area height in pixels. Defaults to 180. | | `typedFontFamily` | `string` | The face used for a typed signature. | | `hint` | `ReactNode` | Guidance shown under the signing area. | ### SignatureValue | Prop | Type | Description | | --- | --- | --- | | `mode` | `"draw" | "type"` | Which kind of signature this is. | | `dataUrl` | `string` | PNG data URL, on a drawn signature. | | `text` | `string` | The typed name, on a typed signature. | ## Accessibility Drawing on a canvas cannot be done with a keyboard, so typing is a first-class method rather than a fallback, and the canvas says so in its accessible name. The method switch is a labelled group of toggle buttons. Clearing is disabled while there is nothing to clear. The canvas is redrawn at the device pixel ratio so a signature is not blurred on a high-density screen. ## Dependencies - lucide-react --- Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed --- # CSV Viewer A real table for delimited data, with sortable columns, a sticky header, and a row cap so a large file cannot lock the page. - Family: Documents - Kind: component - Page: https://ui.tinkererslabs.com/docs/components/csv-viewer - Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/csv-viewer/csv-viewer.tsx ## Install npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/csv-viewer` pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/csv-viewer` yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/csv-viewer` bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/csv-viewer` Or as a package import: ```ts import { CsvViewer } from "mischief-ui/csv-viewer" ``` ## Usage ```tsx export function Preview({ file }: { file: File }) { return } ``` ## Parsing papaparse is an optional peer, imported the first time a source is parsed and never bundled for anyone who does not open a CSV. Without it installed, and without a parser of your own, the component says so rather than failing quietly. Pass table when the data is already parsed -- from your API, from a worker, from a database -- and no parser is involved at all. Pass parser to use something else, or to parse somewhere that will not block the page. ```tsx { const { fields, rows } = await parseInWorker(input) return { fields, rows } }} /> ``` A parser returns { fields, rows }; how it gets there is up to you. Delimiters, quoting, and encoding are the parser's business, not the viewer's. papaparse detects the common ones; a file that needs a fixed delimiter or a particular encoding is a good reason to pass your own. ## Large files Every row given to the component is rendered. maxRows caps what is shown, which keeps a large file from putting hundreds of thousands of cells into the page, and is the difference between a preview that opens instantly and a tab that stops responding. Treat this as a preview of a file rather than a spreadsheet. When someone needs to work through all of it, page or virtualise on your side and hand the viewer one page at a time. ## API | Prop | Type | Description | | --- | --- | --- | | `source` | `string | File` | CSV text or a file to parse. | | `table` | `CsvTable` | Already parsed data as fields and rows. Skips the parser entirely. | | `parser` | `(source) => Promise` | Replaces the default parser. Supply this and papaparse is never loaded. | | `maxRows` | `number` | How many rows to render. Defaults to 200. | | `emptyLabel, loadingLabel` | `ReactNode` | Copy for those two states. | ### CsvTable | Prop | Type | Description | | --- | --- | --- | | `fields` | `string[]` | Column headers, in order. | | `rows` | `string[][]` | Cells per row, aligned to fields. | ## Accessibility The data is a real table with a caption, column headers, and aria-sort on the sorted column, so it can be navigated with table commands rather than read as a wall of text. Sorting is a button inside each header. Numeric columns sort numerically instead of as text. When rows are capped the footer says how many of the total are shown, rather than silently truncating. ## Dependencies - papaparse - lucide-react --- Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed --- # JSON Viewer A collapsible tree for a JSON payload, navigable from the keyboard, where every row can hand you its path. - Family: Documents - Kind: component - Page: https://ui.tinkererslabs.com/docs/components/json-viewer - Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/json-viewer/json-viewer.tsx ## Install npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/json-viewer` pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/json-viewer` yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/json-viewer` bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/json-viewer` Or as a package import: ```ts import { JsonViewer } from "mischief-ui/json-viewer" ``` ## Usage ```tsx export function ToolResult({ payload }) { return ( ) } ``` ## The path is the point Reading a payload is half the job; the other half is saying where in it you were looking. Every row carries a copy control, and what it copies is the value, while the control names the path so the reader can see which one they are about to take. Paths are written the way they would be typed back into code, so a key that cannot survive dot notation is bracketed and quoted instead of being silently mangled. ```tsx result.tools[0].input.query result["content-type"] ``` A plain key, and one that needs brackets. ## How much is open to begin with A tree that arrives fully collapsed is one line, and one that arrives fully expanded is the wall of text the component exists to avoid. defaultExpandedDepth decides how far down the first view goes, and one level is usually enough to show the shape. A branch that is closed still says how much is inside it, so its size is legible without opening it. An empty object or array is a leaf: there is nothing to disclose, so it offers no control that would do nothing. ## Long strings One long string should not decide the width of the panel. Strings past maxStringLength are cut with an ellipsis inside the quotes, and the copy control still yields the whole thing rather than what is shown. ## API | Prop | Type | Description | | --- | --- | --- | | `value` | `unknown` | The data. Anything JSON can hold. | | `rootName` | `string` | What the top row is called, and the first segment of every path. Defaults to "root". | | `defaultExpandedDepth` | `number` | How many levels are open on arrival. Defaults to 1. | | `maxStringLength` | `number` | Where a string is cut for display. Defaults to 120. | | `copyable` | `boolean` | Shows the per-row copy control. Defaults to true. | | `label` | `string` | The tree's accessible name. Defaults to "JSON". | ## Accessibility The rows are a real tree: role=tree on the container, role=treeitem with aria-level on each row, and aria-expanded on the ones that can open, so a screen reader announces depth and state rather than reading an indented list. Arrow keys move and fold the way a tree is expected to behave -- Right opens then descends, Left closes then climbs to the parent -- with Home and End for the ends and Enter or Space to toggle. Only one row is in the tab order, so the tree is a single stop rather than a hundred. A copy is confirmed through a live region, since the icon change alone is not announced. ## Dependencies - lucide-react --- Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed --- # DOCX Viewer Renders a Word document as elements built through an allowlist, so a file you did not write cannot bring its own scripts or links. - Family: Documents - Kind: component - Page: https://ui.tinkererslabs.com/docs/components/docx-viewer - Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/docx-viewer/docx-viewer.tsx ## Install npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/docx-viewer` pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/docx-viewer` yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/docx-viewer` bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/docx-viewer` Or as a package import: ```ts import { DocxViewer } from "mischief-ui/docx-viewer" ``` ## Usage ```tsx export function Contract({ file }: { file: File }) { return } ``` ## What actually reaches the page mammoth converts a .docx into an HTML string. That string is never handed to the browser as markup. It is parsed, walked, and rebuilt as React elements, keeping only tags on an allowlist and only attributes allowed for each of those tags, so a document from someone else cannot introduce script, styling, or event handlers into your page. - Elements outside the allowlist are dropped, and script and style subtrees are dropped whole rather than unwrapped. - href values are checked, and javascript: links are stripped. - Whitespace-only text between structural tags is discarded, so tables and lists do not inherit stray gaps. Widen or narrow the allowlist with allowedTags when your documents need something more, and keep it as small as the documents allow. ## Fidelity This is a structural view, not a page-faithful one. Headings, lists, tables, links, and emphasis survive; page geometry does not. Fonts, margins, columns, headers and footers, page breaks, and anything positioned absolutely are lost, because the source markup does not carry them. When the layout is the point -- a contract that must look like the signed copy -- convert to PDF on the server and use the PDF Viewer instead. ## API | Prop | Type | Description | | --- | --- | --- | | `source` | `ArrayBuffer | Blob` | The document to convert. | | `result` | `DocxResult` | Already converted html and messages. Skips the converter. | | `converter` | `(source: ArrayBuffer) => Promise` | Replaces the default converter. Supply this and mammoth is never loaded. | | `allowedTags` | `readonly string[]` | The tags permitted in the output. Anything else keeps its text and loses its wrapper. | | `showWarnings` | `boolean` | Lists conversion messages under the body. | ## Accessibility Converted markup is never injected. The HTML is parsed and rebuilt as React elements through a tag and attribute allowlist, so event handler attributes cannot survive and a javascript: link loses its href while keeping its text. Links that do survive open in a new tab with noreferrer. The region reports aria-busy while a document is converting. ## Dependencies - mammoth - lucide-react --- Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed --- # PDF Viewer Page-by-page PDF rendering on a canvas, with paging and zoom, over any loader you give it. - Family: Documents - Kind: component - Page: https://ui.tinkererslabs.com/docs/components/pdf-viewer - Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/pdf-viewer/pdf-viewer.tsx ## Install npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/pdf-viewer` pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/pdf-viewer` yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/pdf-viewer` bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/pdf-viewer` Or as a package import: ```ts import { PdfViewer } from "mischief-ui/pdf-viewer" ``` ## Usage ```tsx export function Contract() { return } ``` ## The worker pdf.js renders on a background worker, and it cannot find that worker on its own once your code has been bundled. This is the one thing that reliably goes wrong: without workerSrc the viewer fails at the first document, usually with a message about a missing or mismatched worker. Point it at a copy of the worker you serve yourself. Copy the file out of pdfjs-dist at build time rather than linking a CDN, so the worker version can never drift from the library version. ```tsx // scripts/copy-pdf-worker.mjs import { copyFile } from "node:fs/promises" import { createRequire } from "node:module" const require = createRequire(import.meta.url) const worker = require.resolve("pdfjs-dist/build/pdf.worker.min.mjs") await copyFile(worker, "public/pdf.worker.min.mjs") ``` Run it from your build script, then pass workerSrc="/pdf.worker.min.mjs". ## Bringing your own loader pdfjs-dist is an optional peer, imported dynamically the first time a document opens. Supply loader and it is never imported at all, which is how you swap in your own renderer, reuse a document you already have open, or keep the dependency out of the build entirely. ```tsx myPdfLibrary.open(source)} /> ``` Pass document when you already hold an open handle. The loader is skipped and the viewer renders straight from it. ## What a canvas cannot do Pages are painted to a canvas, so the words in them are pixels. Nothing on the page can be selected, copied, searched with find-in-page, or read by a screen reader, and no amount of ARIA changes that. When the text has to be reachable, pair the viewer with something that carries it: a text layer positioned over the canvas, an extracted transcript beside it, or a link to download the original. Treat this as a requirement rather than an enhancement if the document is the content of your page. ## API | Prop | Type | Description | | --- | --- | --- | | `source` | `string | ArrayBuffer` | The document to open. | | `document` | `PdfDocumentHandle` | An already open document. Skips the loader. | | `loader` | `(source) => Promise` | Replaces the default loader. Supply this and pdfjs-dist is never loaded. | | `page, defaultPage, onPageChange` | `number, number, (page: number) => void` | The current page, controlled or uncontrolled. | | `defaultScale, minScale, maxScale` | `number` | Zoom range. Defaults to 1, 0.5, and 3. | | `workerSrc` | `string` | The pdfjs worker URL, which most bundlers need set explicitly. | ## Accessibility The canvas carries an accessible name naming the document and the page it is showing, and the page counter is a polite live region so moving through a document is announced. Paging and zoom controls are disabled at their limits rather than silently doing nothing. A canvas cannot expose the text underneath it, so pair this with a text layer or a downloadable original when the content has to be readable. ## Dependencies - pdfjs-dist - lucide-react --- Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed --- # Markdown Blocks Extracted document regions rendered as markdown, each one selectable so it can be tied back to where it came from. - Family: Documents - Kind: component - Page: https://ui.tinkererslabs.com/docs/components/markdown-blocks - Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/markdown-blocks/markdown-blocks.tsx ## Install npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/markdown-blocks` pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/markdown-blocks` yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/markdown-blocks` bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/markdown-blocks` Or as a package import: ```ts import { MarkdownBlocks } from "mischief-ui/markdown-blocks" ``` ## Usage ```tsx const blocks = [ { id: "title", kind: "heading", content: "# Master Agreement", page: 1 }, ] export function Layout() { return } ``` ## Why blocks rather than a document Document extraction does not return an essay, it returns pieces: a heading here, a table there, a paragraph that came from page four. Keeping them as separate blocks means each one can be pointed at, highlighted, corrected, or traced back to where it came from, which a single rendered string cannot do. kind is what the extractor thought a block was, and page is where it found it. Both are optional, and both are what make it possible to line this up with a page navigator or a set of bounding boxes over the original. ```tsx ``` ## What the markdown may contain Blocks are rendered with react-markdown and GitHub Flavoured Markdown, so tables, strikethrough, task lists, and bare autolinks all work on top of the usual syntax. Raw HTML inside the content is not rendered as HTML -- there is no rehype-raw here, which is what keeps text extracted from someone else's document from bringing markup into your page. react-markdown and remark-gfm are optional peers, so this component is imported from its own entry and needs both installed alongside. ```tsx npm install mischief-ui react-markdown remark-gfm ``` ## API | Prop | Type | Description | | --- | --- | --- | | `blocks` | `MarkdownBlock[]` | Id, markdown content, and optional kind, page, and label. | | `activeId, defaultActiveId` | `string | null` | The selected block, controlled or uncontrolled. | | `onActiveChange` | `(id: string | null) => void` | Runs when a block is selected or cleared. Pair with Bounding Boxes. | | `showKinds` | `boolean` | Shows the kind and page above each block. | ### MarkdownBlock | Prop | Type | Description | | --- | --- | --- | | `id` | `string` | Unique within the set. Drives selection. | | `kind` | `MarkdownBlockKind` | heading, paragraph, table, list, figure, or footer. | | `content` | `string` | The markdown for this block. | | `page` | `number` | Where it came from in the original. | | `label` | `string` | Overrides the wording of the kind badge. | ## Accessibility Blocks are an ordered list of toggle buttons, so selection is reachable by keyboard and announced. Raw HTML inside a block is not rendered, since react-markdown ignores it unless a raw plugin is added, which this component deliberately does not add. Tables come from GitHub flavoured markdown and render as real tables. ## Dependencies - react-markdown - remark-gfm --- Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed --- # Signature Footer A complete closing section with room for the useful links first and one oversized wordmark at the end. - Family: Blocks - Kind: block - Page: https://ui.tinkererslabs.com/docs/components/signature-footer - Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/signature-footer/signature-footer.tsx ## Install npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/signature-footer` pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/signature-footer` yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/signature-footer` bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/signature-footer` Or as a package import: ```ts import { SignatureFooter } from "mischief-ui/signature-footer" ``` ## Usage ```tsx export function Footer() { return ( } description="Short links and file sharing with real-time analytics." columns={[ { label: "Product", links: [{ label: "Pricing", href: "/pricing" }] }, { label: "Company", links: [{ label: "Docs", href: docsUrl, external: true }] }, ]} related={{ label: "Other products", links: otherProducts, }} renderLink={({ href, label }) => {label}} brand={© 2026 Northstar} legal={} status={} wordmark="northstar" /> ) } ``` ## Links, and who renders them Pass columns and the footer lays out the labelled groups and styles the links itself, so a directory of thirty links is a data structure rather than thirty lines of markup. Pass navigation instead when the shape is unusual enough that you would rather build it. Links are plain anchors unless you say otherwise. renderLink hands each one back to you, which is how a framework's own link component gets used without the footer knowing anything about it. ```tsx renderLink={({ href, label, external }) => external ? ( {label} ) : ( {label} ) } ``` Styling comes back to you as well, so keep it consistent if you take this over. related is the same shape as a column but laid out as a wrapping row above the closing line, set off by a dashed rule. It is for the links that are not part of this product -- a sister site, the rest of a portfolio -- and its label is yours to name. ## Dark or light The default is the page's foreground colour as a ground, which reads as a dark slab under a light site. Every shade inside is mixed from the footer's own text colour rather than a theme token, so setting a different background and text colour on the element is all it takes to move it to a light ground. ```tsx ``` The rules, the muted copy, and the wordmark all follow the text colour. ## The wordmark The oversized word across the bottom is drawn at a fraction of the footer's own colour and clipped by the edge of the page. It is hidden from assistive technology and unselectable, because it is a texture rather than a heading -- a screen reader announcing an enormous brand name at the end of every page is noise. Keep it to one short word. It scales with the viewport and is set to never wrap, so anything long is simply cut off rather than reflowed, and the name you actually want read belongs in brand or meta. ## Filling it in Everything except the heading and the wordmark is optional, and each slot takes whatever you give it. There is no link list baked in, no newsletter form, and no social row -- pass your own navigation and it is laid out with the rest. - eyebrow and heading carry the line you want people to leave with. - action is the single thing you want them to do next, not three things. - navigation takes your own list markup, so the grouping is yours. - brand and meta hold the small print along the bottom edge. This is a server component: it holds no state and no effects, so it can render on the server and ship no JavaScript. Import it from its own entry to keep it that way. ## API | Prop | Type | Description | | --- | --- | --- | | `wordmark` | `string` | The oversized closing brand name. The only required prop. | | `columns` | `FooterColumn[]` | Labelled link columns. Wins over navigation. | | `related` | `FooterColumn` | A wrapping row of links set apart above the closing row. | | `renderLink` | `(link: FooterLink) => ReactNode` | Renders every link, for your framework's link component. | | `heading` | `ReactNode` | A line to lead with, or a logo. Optional. | | `eyebrow` | `ReactNode` | A short label above the heading. | | `description` | `ReactNode` | Supporting copy, held to about 36 characters a line. | | `social` | `ReactNode` | A row of icon links under the description. | | `action` | `ReactNode` | A primary link or button. | | `navigation` | `ReactNode` | Your own markup, when the columns do not fit. | | `brand, meta` | `ReactNode, ReactNode` | Open the closing row: ownership and small print. | | `legal, status` | `ReactNode, ReactNode` | Close it: terms in the middle, a status on the right. | | `className` | `string` | Classes for the footer element. Set the ground here. | ## Accessibility A semantic footer element, and a real heading when you give it one rather than an empty one when you do not. A link marked external opens in a new tab, carries rel=noreferrer noopener, and says so in its accessible name -- led by a comma, because a leading space is dropped when that name is computed. Column labels are plain text rather than headings, so a long directory does not litter the page outline. The oversized wordmark is decoration and hidden from assistive technology. --- Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed --- # Image Gallery A responsive image collection with equal and masonry layouts, plus a lightbox that handles focus, keyboard navigation, and scroll locking. - Family: Blocks - Kind: block - Page: https://ui.tinkererslabs.com/docs/components/image-gallery - Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/image-gallery/image-gallery.tsx ## Install npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/image-gallery` pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/image-gallery` yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/image-gallery` bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/image-gallery` Or as a package import: ```ts import { ImageGallery } from "mischief-ui/image-gallery" ``` ## Usage ```tsx const images = [ { id: "studio", src: "/photos/studio.jpg", alt: "Sunlight across the studio table", width: 1600, height: 1200, caption: "The studio", }, ] export function WorkGallery() { return } ``` ## Grid or masonry The grid gives every image the same cell, which is the right choice when the pictures are alike and comparison matters. Masonry uses CSS columns and lets each image keep its own height, which suits a mixed set where cropping would be a loss. Masonry fills one column top to bottom before starting the next, so the visual order runs down rather than across. Where sequence carries meaning -- pages of a document, steps in order -- use the grid, because the reading order people expect and the order they are laid out in will not match. ## Dimensions and loading Give width and height wherever you know them. They reserve the right space before the image arrives, so the gallery does not reflow underneath the reader as pictures load -- and in masonry, so the columns do not rebalance twice. Everything below the fold should stay lazy. Set loading to "eager" only for the first row or two, which are the ones the reader is waiting on. Base UI supplies the lightbox dialog, with its focus trap, scroll lock, Escape handling, and focus restoration, so this component is imported from its own entry and needs @base-ui/react installed. ## API | Prop | Type | Description | | --- | --- | --- | | `images` | `ImageGalleryItem[]` | Image sources, alt text, captions, and optional downloads. | | `title` | `ReactNode` | The heading above the collection. | | `layout` | `"grid" | "masonry"` | The layout when controlled. | | `defaultLayout` | `"grid" | "masonry"` | The initial uncontrolled layout. | | `onLayoutChange` | `(layout) => void` | Runs when the layout changes. | | `selectedId` | `string | null` | The open image when controlled. | | `onSelectedIdChange` | `(id) => void` | Runs when the lightbox opens, moves, or closes. | | `showLayoutToggle` | `boolean` | Shows or hides the layout control. | | `emptyState` | `ReactNode` | Content shown when the collection is empty. | | `renderImage` | `(image, context) => ReactNode` | Uses a framework image component or another custom renderer. | ## Accessibility Every thumbnail is a named button. Base UI supplies the modal dialog, focus trap, scroll lock, Escape handling, and focus restoration. Left and Right Arrow move between images. Captions, position, and close controls remain available without hover. ## Dependencies - @base-ui/react - lucide-react --- Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed --- # Code Block A code panel with copy, optional line numbers, highlighted lines, and a collapse for anything long. - Family: Code - Kind: component - Page: https://ui.tinkererslabs.com/docs/components/code-block - Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/code-block/code-block.tsx ## Install npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/code-block` pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/code-block` yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/code-block` bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/code-block` Or as a package import: ```ts import { CodeBlock } from "mischief-ui/code-block" ``` ## Usage ```tsx export function Snippet() { return ( ) } ``` ## On syntax highlighting There is none, and that is deliberate. Every highlighter worth using is larger than this entire library, and one baked in would be paid for by everyone who only wanted a copy button. The component is a place to put code, not an opinion about how code should be coloured. When you do want it, highlight on the server and pass the result as children of your own pre, or reach for shiki or Prism directly. The language prop is a label rather than an instruction -- nothing reads it but the header. ## Long code maxLines collapses anything past a point behind a toggle that says how much is hidden, and the copy button always copies the whole source rather than the visible part. Combine it with wrappable when lines are long as well as many, so the reader can choose between scrolling sideways and reading wrapped. ```tsx ``` highlightLines is one-based, matching the gutter. ## API | Prop | Type | Description | | --- | --- | --- | | `code` | `string` | The source to render. A single trailing newline is dropped. | | `filename` | `string` | Shown in the header, and preferred over language. | | `language` | `string` | A short label such as tsx, used when there is no filename. | | `showLineNumbers` | `boolean` | Adds a gutter sized to the highest line number. | | `highlightLines` | `number[]` | One-based lines to mark as the interesting ones. | | `maxLines` | `number` | Collapses anything past this many lines behind a toggle. | | `wrap, wrappable` | `boolean, boolean` | Wrap long lines, and offer a control that overrides it. | | `copyable` | `boolean` | Shows the copy control. Defaults to true. | | `actions` | `ReactNode` | Extra controls placed in the header. | ## Accessibility The code region is focusable so it can be scrolled from the keyboard. Copying announces itself through a polite live region, and the copy control renames itself once it succeeds. A clipboard that refuses -- denied permission, an insecure context, a sandboxed frame -- is caught and reported rather than leaving the control looking like it worked. Line numbers and the diff-style gutter are aria-hidden, so a screen reader reads the source rather than the decoration. There is no syntax highlighting and no highlighting dependency. ## Dependencies - lucide-react --- Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed --- # Diff View A proposed change shown as a unified or side-by-side diff, with optional accept and reject controls. - Family: Code - Kind: component - Page: https://ui.tinkererslabs.com/docs/components/diff-view - Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/diff-view/diff-view.tsx ## Install npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/diff-view` pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/diff-view` yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/diff-view` bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/diff-view` Or as a package import: ```ts import { DiffView } from "mischief-ui/diff-view" ``` ## Usage ```tsx export function Review() { return ( ) } ``` ## How the diff is computed Diffing happens by line, and only when you do not supply hunks yourself. Matching lines at the start and end are peeled off first, so an edit to one line of a long file only ever costs the length of that file. What is left in the middle goes through a longest-common-subsequence table. That table is quadratic, so it is capped: past two million cells the two middles are reported as one wholesale replacement instead of a line-by-line match. You will only meet this with two large and almost entirely different files, and the output stays correct -- it simply stops being minimal. diffLines and toHunks are exported, so you can run the same diff outside the component -- to count what changed before deciding whether to show anything at all. ```tsx import { diffLines, toHunks } from "mischief-ui/diff-view" const lines = diffLines(before, after) const changed = lines.filter((line) => line.kind !== "context") if (changed.length > 0) { show(toHunks(lines, 3)) } ``` ## Bringing your own hunks Pass hunks and the built-in diff is skipped entirely. This is the path to take when something upstream has already done the work and done it better -- git, a language server, or a model that returned a patch -- or when you want word-level detail the line diff cannot produce. ```tsx ``` Supplied hunks win over before and after, which may then be omitted. Line numbers come from beforeNumber and afterNumber on each line rather than being counted, so a hunk starting at line 400 reads as line 400. ## API | Prop | Type | Description | | --- | --- | --- | | `before, after` | `string, string` | The two sides. Diffed by line when no hunks are given. | | `hunks` | `DiffHunk[]` | Precomputed hunks, which win over before and after. | | `filename` | `string` | Shown in the header. Falls back to Proposed change. | | `view` | `"unified" | "split"` | Layout. Defaults to "unified". | | `context` | `number` | Unchanged lines kept either side of a change. Defaults to 3. | | `showLineNumbers` | `boolean` | Shows the number gutters. Defaults to true. | | `onAccept, onReject` | `() => void, () => void` | Adds the decision footer when either is given. | | `status` | `"pending" | "accepted" | "rejected"` | Replaces the controls with the outcome. | ### DiffLine | Prop | Type | Description | | --- | --- | --- | | `kind` | `"context" | "add" | "remove"` | What happened to this line. | | `text` | `string` | The line, without its ending. | | `beforeNumber` | `number` | Line number on the old side. Absent on an addition. | | `afterNumber` | `number` | Line number on the new side. Absent on a removal. | ### DiffHunk | Prop | Type | Description | | --- | --- | --- | | `header` | `string` | The band above the hunk. Generated when omitted. | | `lines` | `DiffLine[]` | The lines in order, context included. | ## Accessibility The diff is a table with a caption naming the file and the added and removed counts, so it can be read without colour. Every line carries a + or - marker alongside its tint for the same reason. Line numbers are aria-hidden decoration. Once a decision is made the outcome is announced through a status region. ## Dependencies - lucide-react --- Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed --- # Terminal Output Streaming command output with stderr called out, an exit code, and scroll that follows without trapping you. - Family: Code - Kind: component - Page: https://ui.tinkererslabs.com/docs/components/terminal-output - Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/terminal-output/terminal-output.tsx ## Install npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/terminal-output` pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/terminal-output` yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/terminal-output` bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/terminal-output` Or as a package import: ```ts import { TerminalOutput } from "mischief-ui/terminal-output" ``` ## Usage ```tsx export function Install() { return ( ) } ``` ## Streaming output Append to the array as lines arrive and the log grows. Keep running true until the command settles, then pass its exit code -- that is what turns the running indicator into a result. ```tsx const [lines, setLines] = useState([]) const [exitCode, setExitCode] = useState() for await (const chunk of process.stdout) { setLines((current) => [...current, { text: chunk }]) } ``` Send stderr through with stream set, rather than merging both into one string, so failures stay distinguishable after the fact. ## Following, and when it stops The log sticks to the newest line while it is already at the bottom. The moment the reader scrolls up it stops following, and it resumes when they come back within a couple of dozen pixels of the end. Reading back through output is therefore never interrupted by more of it arriving. ANSI escape sequences are stripped rather than rendered, so colour codes from a shell do not appear as noise. Colour is not reconstructed: stderr is distinguished, and nothing else is. ## API | Prop | Type | Description | | --- | --- | --- | | `output` | `string | (TerminalLine | string)[]` | A plain string is split on newlines as stdout. | | `command` | `string` | The command that produced the output, shown above it. | | `cwd` | `string` | Working directory, shown beside the command on wider screens. | | `running` | `boolean` | Shows the running indicator and marks the log busy. | | `exitCode` | `number` | Shown once settled. Anything other than zero reads as a failure. | | `maxHeight` | `number | string` | Height before the log scrolls. Defaults to "18rem". | | `follow` | `boolean` | Keeps the newest line in view. Defaults to true. | ### TerminalLine | Prop | Type | Description | | --- | --- | --- | | `text` | `string` | One line, without its ending. ANSI escapes are stripped. | | `stream` | `"stdout" | "stderr"` | Which stream it came from. Defaults to "stdout". | ## Accessibility Output is a log region marked busy while the command runs, so assistive technology reads new lines without the page stealing focus. stderr is distinguished by a data attribute as well as colour. Following is abandoned the moment the reader scrolls up and resumes when they return to the bottom, so reading back is never interrupted. ANSI escape sequences are stripped rather than rendered. ## Dependencies - lucide-react --- Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed --- # Response Actions The row under an answer: copy it, ask again, and rate it. Drops into the actions slot on Message. - Family: Agent UI - Kind: component - Page: https://ui.tinkererslabs.com/docs/components/response-actions - Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/response-actions/response-actions.tsx ## Install npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/response-actions` pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/response-actions` yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/response-actions` bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/response-actions` Or as a package import: ```ts import { ResponseActions } from "mischief-ui/response-actions" ``` ## Usage ```tsx export function Answer() { return ( } > {answer} ) } ``` ## The rating is yours to keep The row reports a rating and remembers nothing. Left uncontrolled it holds the choice for as long as the component is mounted, which is enough for a page that will not outlive the conversation. Pass feedback and it holds nothing at all, and what is shown is whatever you say it is. Control it whenever the rating is stored, so a failed write does not leave a thumb lit for something that was never recorded. ```tsx const [feedback, setFeedback] = useState(null) { setFeedback(next) try { await rate(messageId, next) } catch { setFeedback(feedback) } }} /> ``` Choosing the current rating again clears it, and reports null. Treat that as a real answer -- someone withdrawing an opinion -- rather than as no answer. ## Where it goes Message already has an actions slot beneath its content, and this is built to sit in it. Keeping it there means the controls line up down the conversation instead of drifting with the length of each answer. Only the controls you configure appear, so an answer that cannot usefully be retried simply has no retry button rather than a dead one. Anything else you need -- a share, a report, an overflow menu -- goes in as children and lands at the end of the row. ## API | Prop | Type | Description | | --- | --- | --- | | `copyText` | `string` | Copies this text. The copy control is absent without it. | | `onRetry` | `() => void` | Adds the try-again control. | | `retryLabel` | `string` | Names that control. Defaults to "Try again". | | `onFeedbackChange` | `(feedback: ResponseFeedback) => void` | Turns the rating controls on. | | `feedback, defaultFeedback` | `"up" | "down" | null` | Controlled and uncontrolled rating. | | `label` | `string` | Names the group. Defaults to Response actions. | ## Accessibility The row is a labelled group of named buttons, so each one reads on its own. Ratings are toggles carrying aria-pressed, and choosing the current rating again clears it. Copying announces itself through a polite live region, and a refused clipboard is reported rather than silently doing nothing. The controls are 32px, matching the other compact toolbars in this set rather than the 44px targets used for primary actions; pass a className to enlarge them where this row is the main way to act. ## Dependencies - lucide-react --- Mischief UI · https://ui.tinkererslabs.com/ · MIT licensed --- # Theme Toggle A light and dark switch that survives a reload, follows the system when asked, and stays in step across tabs. - Family: Controls - Kind: component - Page: https://ui.tinkererslabs.com/docs/components/theme-toggle - Source: https://github.com/Tinkerers-Labs/mischief-ui/blob/main/registry/default/theme-toggle/theme-toggle.tsx ## Install npm: `npx shadcn@latest add Tinkerers-Labs/mischief-ui/theme-toggle` pnpm: `pnpm dlx shadcn@latest add Tinkerers-Labs/mischief-ui/theme-toggle` yarn: `yarn dlx shadcn@latest add Tinkerers-Labs/mischief-ui/theme-toggle` bun: `bunx --bun shadcn@latest add Tinkerers-Labs/mischief-ui/theme-toggle` Or as a package import: ```ts import { ThemeToggle } from "mischief-ui/theme-toggle" ``` ## Usage ```tsx export function Header() { return } ``` ## Stopping the flash The toggle cannot prevent a flash of the wrong theme on the first paint, and no component can. The server has no way to know what the reader chose, so the page ships in one theme and corrects itself once React takes over -- which is late enough to see. Fixing it means setting the class before the page paints, with a small blocking script in the document head. This runs once, before anything is rendered, and matches what applyTheme does afterwards. ```tsx // app/layout.tsx const setTheme = `(() => { try { const stored = localStorage.getItem("theme") const dark = stored ? stored === "dark" : matchMedia("(prefers-color-scheme: dark)").matches document.documentElement.classList.toggle("dark", dark) document.documentElement.style.colorScheme = dark ? "dark" : "light" } catch {} })()` export default function RootLayout({ children }) { return (