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.
New listing
Fernworks · Draft · saved 4 min ago
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.
| Prop | Type | Default | Description |
|---|---|---|---|
| value / defaultValue | TwinPreviewListing | — / defaultTwinPreviewListing | The listing: { title, price, photos, size, tags, description }, controlled or initial. |
| onChange | (listing, field) => void | — | Fires from the keystroke, press or upload that changed the listing, with the field it touched. |
| onPublish | (listing) => void | Promise | — | 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 | — | Save draft (or Ctrl/⌘+S); a promise shows it saving. |
| onUpload | (photo, report) => void | Promise | — | 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 | — | The field being edited changed, or focus left the form. |
| library | TwinPreviewPhoto[] | defaultTwinPreviewLibrary | The shots Add photo takes from, in order; each { id, alt, seed, plant?, closeUp? } is drawn in code. |
| required | TwinPreviewFieldId[] | ["photos", "title", "price"] | Fields Publish refuses to go without; their empty places on the card are outlined. |
| sizes | string[] | ["9 cm", "12 cm", "17 cm"] | The pot sizes offered, as a radiogroup. |
| suggestedTags | string[] | ["Pet safe", "Low light", "Trailing", "Air purifying"] | Tags offered as one-press chips. |
| currency | string | "USD" | The price's currency. |
| locale | string | "en-US" | The price's locale. |
| shop | string | "Fernworks" | The shop's name on the card. |
| heading | string | "New listing" | The panel's heading and accessible name. |
| now | number | defaultTwinPreviewNow | The moment the draft's age counts from, in ms; never read from the clock. |
| savedAt | number | null | now − 4 minutes | When the draft was last saved, or null for never. |
| status | "ready" | "loading" | "error" | "ready" | Loading shows skeleton fields and card; error shows Try again. |
| onRetry | () => void | — | Try again was pressed after the listing failed to load. |
| cardStyle | "tile" | "row" | "poster" | "tile" | The shop card: photo above the text, a thumbnail beside it, or a full-bleed poster. |
| layout | "split" | "mirror" | "peek" | "split" | Form then card, card then form, or the card kept in the peek tab even when wide. |
| accent | string | "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 | 540 | The panel's height in px; the form scrolls inside it. |
| disabled | boolean | false | Nothing can be edited, saved or published. |
| className | string | — | Classes for the panel. |