Feedback
Dialog whose body is a well to write in, with the actions in the band around it.
A dialog whose body is something to write in, with the two things to do about it in the band around it.
A dialog asking for a sentence has two jobs on screen at once: hold what is being written, and offer the actions. Dialog puts both on one surface, which is right when the body is a line of prose and wrong when it is a field — nothing about a flat panel says "this is where you type".
Here the writing surface is a well set into the dialog. The recess is the affordance: it says the field is the page and the rest is the frame, before a word has been read.
For a confirmation, a warning, or anything whose body is text to be read rather than written, use Dialog.
Installation
Feedback ships with the library — no separate install.
import { Feedback, Button, Text, CheckIcon, StarIcon, Chip, Rating } from 'panelui-native';
import { View, Linking, Platform } from 'react-native';Or copy the source into your project, to own and edit it:
npx panelui-cli@latest add feedbackUsage
<Feedback open={open} onOpenChange={setOpen}>
<Feedback.Content>
<Feedback.Panel>
<Feedback.Title>What should we fix first?</Feedback.Title>
<Feedback.Close />
<Feedback.Field placeholder="Tell us what got in your way" />
</Feedback.Panel>
<Feedback.Footer>
<Feedback.Cancel />
<Feedback.Submit onSubmit={send} />
</Feedback.Footer>
</Feedback.Content>
</Feedback>Composition
<Feedback>
<Feedback.Trigger>…</Feedback.Trigger> {/* opens it */}
<Feedback.Content> {/* the shell, and the recessed band */}
<Feedback.Panel> {/* the well */}
<Feedback.Title>…</Feedback.Title>
<Feedback.Close /> {/* the ✕, in the well's corner */}
<Feedback.Field /> {/* what is being written */}
</Feedback.Panel>
<Feedback.Footer> {/* in the band, narrower than the well */}
<Feedback.Cancel />
<Feedback.Submit />
</Feedback.Footer>
</Feedback.Content>
</Feedback>Feedback.Trigger— Wraps its child and opens the dialog on press. Leave it out for a dialog opened from somewhere else and driven byopen.Feedback.Content— The shell: the scrim, the recessed band, and the dismiss surface behind it.dismissible={false}takes away the tap-outside and the Android back press;blurfrosts the screen behind instead of dimming it.Feedback.Panel— The well set into the shell. Its corner is the shell's less the shell's padding, so the two curves stay concentric.Feedback.Title— The question. Held clear of the ✕ by padding on the end, so a title you centre needsps-9to match — centred text in a box inset on one side only sits off the panel's middle.Feedback.Close— The ✕ in the well's corner. Drawn at 22 points with the slop that takes its touch box to 48 — a circle large enough to press comfortably would be taller than the line it sits on.Feedback.Field— What is being written. No border and no background of its own: it is already inside a well, and an outline drawn inside one is two edges making the same point. Grows pastminHeightas the message does.Feedback.Footer— The action row, held in from the shell's edge. Narrower than the well on purpose — a row running the full width reads as a third edge of the dialog rather than as two things to press.Feedback.Cancel— Discards and closes. PassonPressto do something else first.Feedback.Submit— Sends, throughonSubmit. Inert while the field is empty, and it does not close the dialog — sending usually has to finish first, and a dialog that closed on the press would take its own error message with it.
Examples
Asking for a sentence
The shape the component is for. The dialog holds the message, Submit refuses to send an empty one, and Cancel closes without asking again.
const [open, setOpen] = useState(false);
<Feedback open={open} onOpenChange={setOpen}>
<Feedback.Trigger>
<Button variant="outline">Give feedback</Button>
</Feedback.Trigger>
<Feedback.Content>
<Feedback.Panel>
<Feedback.Title>What should we fix first?</Feedback.Title>
<Feedback.Close />
<Feedback.Field placeholder="Tell us what got in your way" />
</Feedback.Panel>
<Feedback.Footer>
<Feedback.Cancel />
<Feedback.Submit onSubmit={(message) => send(message)} />
</Feedback.Footer>
</Feedback.Content>
</Feedback>Thanks, then done
Submit hands the message back rather than closing, and this is what that buys. The well swaps for a confirmation while the shell holds still, so the dialog is visibly the same object answering rather than a second one arriving.
The footer is a slot, so it collapses to one button — with nothing left to choose between, a Cancel beside it would only be a second way to do the same thing.
const [sent, setSent] = useState(false);
<Feedback
open={open}
onOpenChange={(next) => {
setOpen(next);
// Opening again starts a new note, not the tail of the last one.
if (next) setSent(false);
}}
>
<Feedback.Content>
<Feedback.Panel>
{sent ? (
<View className="items-center justify-center gap-3" style={{ minHeight: 200 }}>
<View className="h-14 w-14 items-center justify-center rounded-full bg-success">
<CheckIcon size={24} />
</View>
<Text size="lg" weight="semibold">Got it</Text>
<Text size="sm" muted className="text-center">
This goes to the people who can fix it.
</Text>
</View>
) : (
<>
<Feedback.Title>What should we fix first?</Feedback.Title>
<Feedback.Close />
<Feedback.Field placeholder="Tell us what got in your way" />
</>
)}
</Feedback.Panel>
<Feedback.Footer>
{sent ? (
<Feedback.Submit disabled={false} onPress={() => setOpen(false)}>
Done
</Feedback.Submit>
) : (
<>
<Feedback.Cancel />
<Feedback.Submit onSubmit={() => setSent(true)} />
</>
)}
</Feedback.Footer>
</Feedback.Content>
</Feedback>The send that failed
The failure the API is shaped around. Because Submit does not close the dialog, a send that fails still has somewhere to say so — and the message is still there to try again with. A dialog that closed on the press would have taken both with it.
It takes the well, in the place the confirmation takes on the way through. A line pinned under the field would be an annotation on the writing; this is not about the writing, it is what happened to it, and it is the whole state of the dialog until somebody answers it.
The send that works takes the same well. Closing on success would leave the retry unacknowledged, so the last state is a confirmation with one action, and the ✕ goes with it — a ✕ beside a single Done is two ways off the same screen.
Hold the message in your own state and Try again puts it straight back. Cancel becomes Back for the same reason: the note is why somebody is still here.
const [state, setState] = useState('idle');
<Feedback.Content dismissible={state !== 'sending'}>
<Feedback.Panel>
{state === 'sent' ? null : <Feedback.Close />}
{state === 'sent' ? (
<Outcome tone="success" title="Sent">
It went through on the second try. Nothing left to do here.
</Outcome>
) : state === 'failed' ? (
<Outcome tone="destructive" title="Could not send">
Your note is still here. Try again when you have a signal.
</Outcome>
) : (
<>
<Feedback.Title>What went wrong?</Feedback.Title>
<Feedback.Field
editable={state !== 'sending'}
placeholder="What were you doing when it happened"
/>
</>
)}
</Feedback.Panel>
<Feedback.Footer>
{state === 'sent' ? (
<Feedback.Submit disabled={false} onPress={() => setOpen(false)}>
Done
</Feedback.Submit>
) : (
<>
<Feedback.Cancel
onPress={state === 'failed' ? () => setState('idle') : undefined}
>
{state === 'failed' ? 'Back' : 'Cancel'}
</Feedback.Cancel>
<Feedback.Submit
disabled={state === 'sending' || draft.trim().length === 0}
onPress={() => setState('sending')}
>
{state === 'sending' ? 'Sending…' : state === 'failed' ? 'Try again' : 'Submit'}
</Feedback.Submit>
</>
)}
</Feedback.Footer>
</Feedback.Content>Tags over the field
Nobody triages free text. The chips sort the report and the sentence explains it, so either one on its own is enough to send — which is why Submit is gated on both rather than on the field alone.
The field is shorter here. The chips have taken the top of the well, and one at full height would push the actions off a small screen.
Sending swaps the well for a confirmation rather than closing, and the confirmation says what the chips bought: a report that has been sorted goes somewhere specific, and that is worth one line.
const REASONS = ['Too slow', 'Confusing', 'Wrong result', 'Crashed'];
const [tags, setTags] = useState([]);
const [sent, setSent] = useState(false);
<Feedback.Panel>
{sent ? (
<Outcome tone="success" title="Filed">
We sort these by what you picked, so it lands with the people who own
that part.
</Outcome>
) : (
<>
<Feedback.Title>What went wrong?</Feedback.Title>
<Feedback.Close />
<View className="flex-row flex-wrap gap-2">
{REASONS.map((reason) => (
<Chip
key={reason}
size="sm"
selected={tags.includes(reason)}
onPress={() => toggle(reason)}
>
{reason}
</Chip>
))}
</View>
<Feedback.Field minHeight={120} placeholder="Anything else we should know" />
</>
)}
</Feedback.Panel>
<Feedback.Footer>
{sent ? (
<Feedback.Submit disabled={false} onPress={() => setOpen(false)}>
Done
</Feedback.Submit>
) : (
<>
<Feedback.Cancel />
<Feedback.Submit
disabled={tags.length === 0 && draft.trim().length === 0}
onSubmit={() => {
setDraft('');
setSent(true);
}}
/>
</>
)}
</Feedback.Footer>Score first, words second
Asking for both at once gets the score and an empty box. Asking for the score first costs one tap and gives the sentence something to be about — and the question can change with the answer, which is the difference between "What do you like most?" and "What would have helped?".
The footer is a slot, so Cancel becomes Back for the second half. The ✕ stays outside the step: it is not the back button and must not come and go with it.
Sending opens a third step instead of closing, and that is where the store goes. Somebody who has just rated the app is the person most likely to rate it publicly, and this is the only moment you have them. The heading turns on the score so a low one is not congratulated, and the store button stays either way — a rating is worth asking for even when the answer was three stars.
APP_STORE_URL and PLAY_STORE_URL are the two lines to replace. The iOS one is the product URL with ?action=write-review on it, which opens straight on the review sheet; the Android one is the Play listing for the package name. Platform.select picks the pair once, at module scope — the platform does not change under a running app.
/** Replace both before shipping. */
const APP_STORE_URL = 'https://apps.apple.com/app/id000000000?action=write-review';
const PLAY_STORE_URL = 'https://play.google.com/store/apps/details?id=com.example.app';
const STORE = Platform.select({
ios: { label: 'App Store', url: APP_STORE_URL },
android: { label: 'Google Play', url: PLAY_STORE_URL },
default: { label: 'Rate the app', url: APP_STORE_URL },
});
const [step, setStep] = useState(0);
const [score, setScore] = useState(0);
const liked = score >= 4;
<Feedback.Panel>
{step === 2 ? null : <Feedback.Close />}
{step === 0 ? (
<View className="items-center justify-center gap-4" style={{ minHeight: 200 }}>
<Feedback.Title className="ps-9 text-center">How are you finding the app?</Feedback.Title>
<Rating size="lg" value={score} onValueChange={setScore} />
</View>
) : step === 1 ? (
<>
<Feedback.Title>
{liked ? 'What do you like most?' : 'What would have helped?'}
</Feedback.Title>
<Feedback.Field placeholder="In your own words" />
</>
) : (
<Outcome tone="success" title={liked ? 'Glad you like it' : "Thanks — we'll fix it"}>
{liked
? 'A rating on the store is how other people find it. It takes a moment.'
: 'This goes straight to the team. A rating still helps, if you have one in you.'}
</Outcome>
)}
</Feedback.Panel>
<Feedback.Footer>
{step === 0 ? (
<>
<Feedback.Cancel />
<Feedback.Submit disabled={score === 0} onPress={() => setStep(1)}>
Next
</Feedback.Submit>
</>
) : step === 1 ? (
<>
<Feedback.Cancel onPress={() => setStep(0)}>Back</Feedback.Cancel>
<Feedback.Submit onSubmit={() => setStep(2)} />
</>
) : (
<>
<Feedback.Cancel>Done</Feedback.Cancel>
<Feedback.Submit
disabled={false}
onPress={() => void Linking.openURL(STORE.url).catch(() => {})}
>
{STORE.label}
</Feedback.Submit>
</>
)}
</Feedback.Footer>Holding the message yourself
Pass value and onValueChange for a draft that survives the dialog being closed, or a message checked as it is typed. Submit reads the same value either way.
const [draft, setDraft] = useState('');
<Feedback
open={open}
onOpenChange={setOpen}
value={draft}
onValueChange={setDraft}
>
…
</Feedback>Frosting what is behind
blur replaces the dim with a frost, so the screen the feedback is about stays legible as shape and colour. It falls back to the dim where expo-blur is not installed.
<Feedback.Content blur>…</Feedback.Content>Variants
tone
cancelsubmit
<Feedback tone="cancel">…</Feedback>
<Feedback tone="submit">…</Feedback>API Reference
Feedback
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controlled open state. |
onOpenChange | (open: boolean) => void | — | |
defaultOpen | boolean | false | Initial state when uncontrolled. |
value | string | — | The message, when the caller holds it. Leave unset to let the field keep it. |
defaultValue | string | '' | Starting message for an uncontrolled field. Ignored once value is passed. |
onValueChange | (value: string) => void | — |
Feedback.Content
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
dismissible | boolean | true | Whether tapping outside or pressing back closes it. |
blur | boolean | false | Frost the screen behind instead of dimming it. Needs expo-blur. |
Feedback.Panel
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
Feedback.Close
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
label | string | 'Close' | How the ✕ announces itself. |
onPress | () => void | — | Runs instead of closing. Call onOpenChange yourself if you pass this. |
Feedback.Field
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
value | string | — | The message. Leave unset to let the dialog hold it. |
onChangeText | (value: string) => void | — | |
minHeight | number | 200 | Room to write in before the field starts growing. |
Feedback.Footer
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
Feedback.Action
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
labelClassName | string | — | |
disabled | boolean | — | |
onPress | () => void | — |
Feedback.Submit
| Prop | Type | Default | Description |
|---|---|---|---|
onSubmit | (value: string) => void | — | Hand the message to the caller. The dialog does not close itself here — sending usually has to finish first, and a dialog that closed on the press would take its own error message with it. |
Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.
Notes
The two actions are equal, and one of them is not
Cancel and Submit take the same width, because they are the same size of decision — this is a sentence somebody wrote, not a deletion. What separates them is weight: Submit is filled in the foreground colour and Cancel is a tint of it.
Disabled, Submit drops the fill rather than dimming it. A tinted accent pill still reads as the thing to press at any opacity, so an inert one gets pressed and then gets reported as broken. The tint it falls back to is fainter than Cancel's, so the pair reads as one live action and one dead one rather than as two Cancels.
Where the recess comes from
The shell is the popover surface with --color-inset laid over it rather than a colour of its own. That token is a translucent black in every theme, so the shell always comes out darker than the panel it holds.
The surface ladder cannot do this job: it runs darker in a light theme and lighter in a dark one, and the recess has to read the same way in both.
Holding the message
Leave value unset and the dialog keeps the message, which is enough for a form that is submitted and forgotten. Pass value and onValueChange to hold it yourself — for a draft that survives the dialog being closed, or a field validated as it is typed.
Submit reads whichever one is in play, so it refuses an empty message either way. Empty feedback is worse than none: it is sent by somebody who believes they said something.
The keyboard, and the caret
The dialog lifts clear of the keyboard on focus and settles back when it goes. It lifts by the overlap rather than travelling with the keyboard, because it is centred on the screen rather than pinned to an edge.
The caret is left to the platform. Every system draws its own accent there, and a field that overrides it is a field that looks like it belongs to a different phone.
The well and the footer are slots
Neither is fixed. The well takes whatever the question needs — a rating, a row of tags, a confirmation — and the footer takes however many actions the step has, so it can collapse to one or swap Cancel for Back. The shell around them does not move while they change, which is what makes a dialog that answers you read as the same object rather than a second one arriving.
That is the reason Submit hands the message back instead of closing. Without it there is nothing on the other side of the press: no confirmation, and nowhere for a failed send to say so.
Public exports
Values: Feedback
Types: FeedbackProps, FeedbackContentProps, FeedbackPanelProps, FeedbackCloseProps, FeedbackFieldProps, FeedbackFooterProps, FeedbackActionProps, FeedbackSubmitProps