Write a review block
A required SvRating, a title, a body with a 30-character minimum and counter, up to three photos in SvFileUpload and a yes/no SvSegmented. Submit validates, shows a loading state, then a thank-you view with the posted review.
A live, editable Svelte 5 component example from the SvGrid gallery (Blocks). Read the SvGrid UI component docs for the full API.
About this example
A Svelte write-a-review form built from the SvGrid UI kit: a required SvRating star input, an SvTextInput title, an SvTextArea body with a 30-character minimum and a live counter, an SvFileUpload for up to three images and an SvSegmented yes/no recommend control. Submitting validates every field, shows the SvButton loading state, then swaps the form for a thank-you view that previews the posted review with its photos.
Write a review block - the review form of a product page. SvRating is the required star input, SvTextInput takes the title, SvTextArea the body (30 characters minimum, live counter in the hint), SvFileUpload up to three images, and SvSegmented a yes/no "would you recommend". Submit validates, shows the SvButton loading state, then a thank-you view with the posted review. Pure UI-kit, --sg-* themed.
Headless: createReviewForm() below owns the fields, validation and the submit flow with no markup. The component under it is one styled render of that core; import createReviewForm from this file (or npx @svgrid/ui add reviews-write --headless for the core alone) to draw your own.
Imports, features and API used
Imports: @svgrid/grid
Frequently asked questions
How does validation work?
createReviewForm() derives an error for each required field: no stars chosen, an empty title, or a body under 30 characters after trimming. Errors stay hidden until the first submit; after that each field shows its message through the error and invalid props and clears as soon as it is fixed. A failed submit calls onInvalid with the first bad field, which the block uses to focus it.
How are photos limited?
SvFileUpload gets accept="image/*", multiple, maxFiles={3} and maxSize of 5 MB. Files that are not images, are too big or go past three are rejected by the component and reported through onReject, which the block turns into a warning toast. The kept files are bound to the core and previewed with object URLs that are revoked when the selection changes.
How do I post the review to my backend?
Pass onSubmit to createReviewForm. It receives rating, title, body, photos and recommend; build a FormData with the photos appended and POST it. While the promise is pending the submit button shows its loading state; if it throws, the form stays open with the values intact, and if it resolves the thank-you view appears.
Related documentation
Related articles
- Web Components - Write Once, Use in React, Vue, and Angular - How to wrap SvGrid as a custom element, wire it across React, Vue, and Angular, and avoid the three interop traps that bite every team the first time.
Source code (609-block-reviews-write.svelte)
<script lang="ts" module>
/**
* Write a review block - the review form of a product page. SvRating is the
* required star input, SvTextInput takes the title, SvTextArea the body (30
* characters minimum, live counter in the hint), SvFileUpload up to three
* images, and SvSegmented a yes/no "would you recommend". Submit validates,
* shows the SvButton loading state, then a thank-you view with the posted
* review. Pure UI-kit, --sg-* themed.
*
* Headless: createReviewForm() below owns the fields, validation and the
* submit flow with no markup. The component under it is one styled render of
* that core; import createReviewForm from this file (or `npx @svgrid/ui add
* reviews-write --headless` for the core alone) to draw your own.
*/
export type ReviewField = 'rating' | 'title' | 'body'
export type ReviewValues = {
rating: number; title: string; body: string; photos: File[]; recommend: 'yes' | 'no' | null
}
export type ReviewFormOptions = {
minBody?: number
/** Post the review; throw (or reject) to keep the form open. */
onSubmit?: (values: ReviewValues) => Promise<void> | void
/** Called with the first invalid field after a failed submit (focus it). */
onInvalid?: (field: ReviewField) => void
}
export const RATING_WORDS = ['', 'Poor', 'Fair', 'Good', 'Very good', 'Excellent']
export function createReviewForm(opts: ReviewFormOptions = {}) {
const minBody = opts.minBody ?? 30
let rating = $state(0)
let title = $state('')
let body = $state('')
let photos = $state<File[]>([])
let recommend = $state<'yes' | 'no' | null>(null)
let tried = $state(false)
let status = $state<'form' | 'sending' | 'done'>('form')
const length = $derived(body.trim().length)
const errors = $derived<Partial<Record<ReviewField, string>>>({
rating: rating === 0 ? 'Choose a star rating' : undefined,
title: title.trim() === '' ? 'Add a short title' : undefined,
body: length < minBody ? `Write at least ${minBody} characters (${length} so far)` : undefined,
})
async function submit(ev?: Event) {
ev?.preventDefault()
if (status !== 'form') return
tried = true
const first = (['rating', 'title', 'body'] as const).find((k) => errors[k])
if (first) { opts.onInvalid?.(first); return }
status = 'sending'
try {
await opts.onSubmit?.({ rating, title: title.trim(), body: body.trim(), photos, recommend })
status = 'done'
} catch {
status = 'form'
}
}
function reset() {
rating = 0; title = ''; body = ''; photos = []; recommend = null; tried = false
status = 'form'
}
return {
minBody,
get rating() { return rating },
set rating(v: number) { rating = v },
get title() { return title },
set title(v: string) { title = v },
get body() { return body },
set body(v: string) { body = v },
get photos() { return photos },
set photos(v: File[]) { photos = v },
get recommend() { return recommend },
set recommend(v: 'yes' | 'no' | null) { recommend = v },
get status() { return status },
/** Counter text for the body field. */
get bodyHint() { return length < minBody ? `${length} / ${minBody} characters minimum` : `${length} characters` },
/** A field's error, shown only after the first submit attempt. */
error: (f: ReviewField) => (tried ? errors[f] : undefined),
submit,
reset,
/** Spread on your <form>. */
formProps: () => ({ novalidate: true, onsubmit: submit }),
}
}
</script>
<script lang="ts">
import {
SvRating, SvTextInput, SvTextArea, SvFileUpload, SvSegmented, SvButton, SvBadge,
SvToaster, toast,
} from '@svgrid/grid'
import type { FileRejection } from '@svgrid/grid'
const form = createReviewForm({
// Replace with your POST (FormData with the photos); a throw keeps the form open.
onSubmit: () => new Promise<void>((res) => setTimeout(res, 1000)).then(() => { toast.success('Review posted') }),
onInvalid: (f) => document.getElementById(`sv-review-write-${f}`)?.focus(),
})
const recommendOptions = [{ value: 'yes', label: 'Yes' }, { value: 'no', label: 'No' }]
const setRecommend = (v: string | number) => (form.recommend = v === 'yes' ? 'yes' : 'no')
// Local previews of the chosen photos; revoked when the selection changes.
const previews = $derived(form.photos.map((f) => URL.createObjectURL(f)))
$effect(() => {
const urls = previews
return () => urls.forEach((u) => URL.revokeObjectURL(u))
})
function rejected(list: FileRejection[]) {
const why = { type: 'is not an image', size: 'is over 5 MB', count: 'is past the 3-photo limit' }
toast.warning(list.map((r) => `${r.file.name} ${why[r.reason]}`).join('. '))
}
</script>
<main class="sv-review-write">
<div class="sv-review-write-panel">
<div class="sv-review-write-product">
<span class="sv-review-write-thumb" aria-hidden="true"></span>
<div>
<span class="sv-review-write-muted">Reviewing</span>
<strong>Fieldline Trail Pack 28L</strong>
</div>
</div>
{#if form.status === 'done'}
<div class="sv-review-write-done" role="status">
<span class="sv-review-write-check" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M5 12.5l4.5 4.5L19 7.5" /></svg>
</span>
<h2>Thanks for your review</h2>
<p class="sv-review-write-muted">It is live on the product page. Here is how it looks:</p>
</div>
<article class="sv-review-write-preview">
<div class="sv-review-write-preview-head">
<SvRating value={form.rating} readonly size="sm" ariaLabel={`Rated ${form.rating} out of 5`} />
{#if form.recommend === 'yes'}<SvBadge variant="success" size="sm">Recommends</SvBadge>{/if}
</div>
<h3>{form.title}</h3>
<p>{form.body}</p>
{#if previews.length}
<div class="sv-review-write-photos">
{#each previews as src, i (src)}<img {src} alt={`Photo ${i + 1} of ${previews.length}`} />{/each}
</div>
{/if}
<span class="sv-review-write-muted">Ada Lovelace - Verified purchase</span>
</article>
<SvButton variant="outline" onclick={form.reset}>Write another review</SvButton>
{:else}
<h2>Write a review</h2>
<form class="sv-review-write-form" {...form.formProps()}>
<div class="sv-review-write-rating">
<SvRating
id="sv-review-write-rating" name="rating" label="Overall rating" size="lg" required
value={form.rating} onChange={(v) => (form.rating = v)}
invalid={!!form.error('rating')} error={form.error('rating')}
/>
<span class="sv-review-write-word" aria-hidden="true">{RATING_WORDS[form.rating]}</span>
</div>
<SvTextInput
id="sv-review-write-title" name="title" label="Title" bind:value={form.title} maxlength={80} required block
placeholder="Sum it up in a line"
invalid={!!form.error('title')} error={form.error('title')}
/>
<SvTextArea
id="sv-review-write-body" name="body" label="Review" bind:value={form.body} rows={5} maxlength={2000} required block
placeholder="What did you use it for? How did it hold up?"
hint={form.bodyHint}
invalid={!!form.error('body')} error={form.error('body')}
/>
<SvFileUpload
label="Photos (optional)" name="photos" hint="Up to 3 images, 5 MB each" accept="image/*" multiple
maxFiles={3} maxSize={5 * 1024 * 1024} bind:files={form.photos} onReject={rejected} block
/>
<SvSegmented
name="recommend" label="Would you recommend it?" options={recommendOptions}
value={form.recommend} onChange={setRecommend}
/>
<div class="sv-review-write-actions">
<span class="sv-review-write-muted">Posting as Ada Lovelace</span>
<SvButton type="submit" variant="primary" loading={form.status === 'sending'}>Submit review</SvButton>
</div>
</form>
{/if}
</div>
</main>
<SvToaster position="bottom-right" />
<style>
/* Empty stars from the theme: the kit falls back to a light grey that glares on dark themes. */
.sv-review-write :global(.sv-rating) { --_off: var(--sg-rating-empty, color-mix(in srgb, var(--sg-fg, #0f172a) 22%, var(--sg-bg, #fff))); }
.sv-review-write {
width: 100%; box-sizing: border-box; min-height: 560px;
display: flex; justify-content: center; padding: 32px 24px;
color: var(--sg-fg, #0f172a); font-family: var(--sg-font, inherit);
}
.sv-review-write-panel {
width: 100%; max-width: 560px; box-sizing: border-box; align-self: flex-start;
display: flex; flex-direction: column; gap: 18px; padding: 28px;
background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius-lg, 12px);
}
.sv-review-write h2 { margin: 0; font-size: 21px; font-weight: 700; letter-spacing: -.01em; }
.sv-review-write-muted { font-size: 12.5px; color: var(--sg-muted, #64748b); }
.sv-review-write-product { display: flex; align-items: center; gap: 12px; padding-bottom: 16px; border-bottom: 1px solid var(--sg-border, #e2e8f0); }
.sv-review-write-product div { display: flex; flex-direction: column; }
.sv-review-write-product strong { font-size: 14px; }
.sv-review-write-thumb {
width: 44px; height: 44px; flex: none; border-radius: var(--sg-radius, 8px);
background: linear-gradient(160deg, color-mix(in srgb, var(--sg-fg, #0f172a) 18%, var(--sg-bg, #fff)) 0 38%, color-mix(in srgb, var(--sg-fg, #0f172a) 32%, var(--sg-bg, #fff)) 38% 100%);
}
.sv-review-write-form { display: flex; flex-direction: column; gap: 18px; }
.sv-review-write-rating { display: flex; align-items: flex-end; gap: 12px; }
.sv-review-write-word { font-size: 13px; font-weight: 600; color: var(--sg-muted, #64748b); padding-bottom: 6px; min-height: 1em; }
.sv-review-write-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 4px; }
.sv-review-write-done { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; }
.sv-review-write-done p { margin: 0; }
.sv-review-write-check {
display: inline-flex; width: 40px; height: 40px; align-items: center; justify-content: center; border-radius: 50%;
background: color-mix(in srgb, var(--sg-success, #16a34a) 16%, transparent); color: var(--sg-success, #16a34a);
}
.sv-review-write-check svg { display: block; width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
.sv-review-write-preview {
padding: 16px; border-radius: var(--sg-radius, 8px);
background: color-mix(in srgb, var(--sg-fg, #0f172a) 5%, var(--sg-bg, #fff));
overflow-wrap: anywhere;
}
.sv-review-write-preview-head { display: flex; align-items: center; gap: 10px; }
.sv-review-write-preview h3 { margin: 10px 0 4px; font-size: 15px; font-weight: 650; }
.sv-review-write-preview p { margin: 0 0 10px; font-size: 14px; line-height: 1.55; white-space: pre-line; }
.sv-review-write-photos { display: flex; gap: 8px; margin-bottom: 10px; }
.sv-review-write-photos img { display: block; width: 64px; height: 64px; object-fit: cover; border-radius: 6px; border: 1px solid var(--sg-border, #e2e8f0); }
@media (max-width: 600px) {
.sv-review-write { padding: 16px; min-height: 0; }
.sv-review-write-panel { padding: 20px 16px; }
.sv-review-write-actions { flex-direction: column-reverse; align-items: stretch; text-align: center; }
}
</style>More Blocks examples
- Login block - A two-column authentication page: a branded gradient panel beside a sign-in SvCard with social SvButtons, SvTextInput + SvPasswordInput (reveal), remember-me SvCheckBox, an "or" SvDivider and forgot-password link. Drops into any route as-is.
- Sign-up block - A centered account-creation card: two-up name fields, work email, a password with the live strength meter, a plan SvSegmented switch, terms SvCheckBox and a benefits rail. Submit fires a promise toast. Pure UI-kit.
- Two-factor / OTP block - The verification step of a sign-in flow: SvOtpInput (6 digits, auto-advance + onComplete), a resend cooldown timer, and the waiting -> verifying -> verified states. Enter 000000 to see the error path.
- Analytics dashboard block - A full overview page: a range SvSegmented + actions header, a KPI grid (SvStat + SvSparkline), a revenue bar panel, a recent-activity SvTimeline and a top-products list with SvProgress bars. No grid dependency.
- App shell block - A complete application layout: a SvNavPane sidebar with sections + badges, a top bar with SvBreadcrumb, search and an account SvMenu, and a routed content area. Nav swaps the page; the toggle collapses the rail to an icon rail.