KQ-1673 · TWIN/PREVIEW

Twin Preview

A listing editor with the shop card it produces beside it, both driven by one value: the field you are on and its place on the card are lit by twin lenses that glide to each new focus on springs.glide, joined by a thin tether whose ends ride the same springs, and every edit ripples out of the region it changed while the price rolls its digits on springs.snap and new tags pop in on snap. Photos upload inside the card under a scrim whose fill glides to each report, empty required fields stand on the card as dashed ghosts that turn red when Publish refuses, and a Publish that lands brings a Live badge in on springs.recoil. Every field is a real control — photos are one list stop where arrows move, Shift with an arrow reorders, Enter makes a cover and Delete removes — Ctrl/⌘+S saves the draft, Ctrl/⌘+Enter publishes, and below a 36rem container the card waits in a peek tab that rises as a sheet.

KQ-1673 · TWIN/PREVIEW

New listing

Fernworks · Draft · saved 4 min ago

The first is the cover

Left and Right move between photos. Shift with Left or Right moves the photo. Enter makes it the cover. Delete removes it.

28/60
2/6

Enter or a comma adds the tag. Backspace in an empty field removes the last one.

TrailingBright light
118/200
Fernworks

String of pearls, variegated

$24.00
12 cm pot
TrailingBright light

Cream-edged pearls on long trailing strands. Water when the beads start to pucker and let the soil dry between drinks.

draft · 6 of 6 fields filled

Install

One command — the source lands in your repo. Or copy it from the Code tab.

$ pnpm dlx shadcn@latest add @kinetiq/twin-preview

Props

value / defaultValue
TwinPreviewListing
default — / defaultTwinPreviewListing
The listing: { title, price, photos, size, tags, description }, controlled or initial.
onChange
(listing, field) => void
default —
Fires from the keystroke, press or upload that changed the listing, with the field it touched.
onPublish
(listing) => void | Promise
default —
Publish was pressed with every required field filled; a promise holds the button until it settles, a rejection keeps everything and says so.
onSaveDraft
(listing) => void | Promise
default —
Save draft (or Ctrl/⌘+S); a promise shows it saving.
onUpload
(photo, report) => void | Promise
default —
Uploads a photo the listing just gained; call report(0–1) as it goes. Omit it and photos arrive at once.
onActiveFieldChange
(field | null) => void
default —
The field being edited changed, or focus left the form.
library
TwinPreviewPhoto[]
default defaultTwinPreviewLibrary
The shots Add photo takes from, in order; each { id, alt, seed, plant?, closeUp? } is drawn in code.
required
TwinPreviewFieldId[]
default ["photos", "title", "price"]
Fields Publish refuses to go without; their empty places on the card are outlined.
sizes
string[]
default ["9 cm", "12 cm", "17 cm"]
The pot sizes offered, as a radiogroup.
suggestedTags
string[]
default ["Pet safe", "Low light", "Trailing", "Air purifying"]
Tags offered as one-press chips.
currency
string
default "USD"
The price's currency.
locale
string
default "en-US"
The price's locale.
shop
string
default "Fernworks"
The shop's name on the card.
heading
string
default "New listing"
The panel's heading and accessible name.
now
number
default defaultTwinPreviewNow
The moment the draft's age counts from, in ms; never read from the clock.
savedAt
number | null
default now − 4 minutes
When the draft was last saved, or null for never.
status
"ready" | "loading" | "error"
default "ready"
Loading shows skeleton fields and card; error shows Try again.
onRetry
() => void
default —
Try again was pressed after the listing failed to load.
cardStyle
"tile" | "row" | "poster"
default "tile"
The shop card: photo above the text, a thumbnail beside it, or a full-bleed poster.
layout
"split" | "mirror" | "peek"
default "split"
Form then card, card then form, or the card kept in the peek tab even when wide.
accent
string
default "var(--accent-bright)"
The lenses, tether, ripples, price and Publish — any CSS colour; buttons take its hue at a fixed lightness so white text reads in both themes.
height
number
default 540
The panel's height in px; the form scrolls inside it.
disabled
boolean
default false
Nothing can be edited, saved or published.
className
string
default —
Classes for the panel.