Contact blocks
Contact sections for a marketing site or an app's help page: a plain contact
form, cards per team, and a support form with opening hours. Each block is one
.svelte file built from SvTextInput, SvTextArea, SvDropDownList,
SvSegmented, SvModal and SvButton, themed from the --sg-* tokens.
Installation
Add the whole group, or one block by its name (shown under each demo):
See them first with npx @svgrid/ui try blocks/contact. No project needed.
Centered contact form
A heading and one line over a form: name, email, a topic dropdown, a message
with a 1000-character counter and a required privacy checkbox. Send it empty to
see every field error; a valid send shows the button's loading state, then
replaces the form with a confirmation that names the reply address. The form
carries a hidden website honeypot field for the server to check.
npx @svgrid/ui add contact-centered
Contact cards
Sales, Support, Press and Partnerships cards, each with an icon, one line, an
email link and a button. Book a demo opens an SvModal with name, work email and
team size; a sent request closes the dialog and marks the Sales card. Four cards
across, two below 980 px, one below 560 px.
npx @svgrid/ui add contact-cards
Contact with support hours
A ticket form with a Low / Normal / Urgent priority next to a hours panel: the week's hours with today highlighted, an "Open now" or "Opens at 8:00" badge from the viewer's clock, and the reply time per channel. Changing the priority changes the promised times, and the confirmation toast quotes the email time.
npx @svgrid/ui add contact-hours
Handling the form in SvelteKit
Send contact mail from the server. A mail provider key in the browser is a key anyone can use to send mail as you, and a client-side send skips your validation. The pattern below posts the centered form to a form action, checks every field again on the server, drops bots that fill the honeypot, and calls the mail provider from server code.
Each block keeps its state in a core exported from its <script module>:
createContactForm(), createContactChannels() and createSupportRequest().
They take your data and callbacks as options and fall back to the sample data.
The mail call lives in a server-only module, so its key never reaches the browser bundle. This one is a stand-in for your provider's send endpoint:
// src/lib/server/mail.ts
import { env } from '$env/dynamic/private'
export async function sendMail(msg: { to: string; replyTo: string; subject: string; text: string }) {
const res = await fetch('https://api.mail-provider.example/v1/send', {
method: 'POST',
headers: { authorization: `Bearer ${env.MAIL_API_KEY}`, 'content-type': 'application/json' },
body: JSON.stringify({ from: '[email protected]', ...msg }),
})
if (!res.ok) throw new Error(`Mail provider returned ${res.status}`)
}
The action reads the same names the block gives its fields. SvTextInput,
SvTextArea and SvDropDownList each render a hidden <input> with their
value when name is set, and SvCheckBox sends true when ticked:
// src/routes/contact/+page.server.ts
import { fail } from '@sveltejs/kit'
import type { Actions } from './$types'
import { sendMail } from '$lib/server/mail'
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
const TOPICS = new Set(['sales', 'support', 'billing', 'other'])
export const actions: Actions = {
contact: async ({ request }) => {
const data = await request.formData()
// Honeypot: people never see this field. Answer as if it worked so the bot moves on.
if (String(data.get('website') ?? '') !== '') return { sent: true }
const name = String(data.get('name') ?? '').trim()
const email = String(data.get('email') ?? '').trim()
const topic = String(data.get('topic') ?? '')
const message = String(data.get('message') ?? '').trim()
const errors: Record<string, string> = {}
if (!name || name.length > 100) errors.name = 'Tell us your name.'
if (!EMAIL_RE.test(email)) errors.email = 'Enter a valid email, like [email protected].'
if (!TOPICS.has(topic)) errors.topic = 'Pick a topic so the right team reads it.'
if (message.length < 20 || message.length > 1000) errors.message = 'Write between 20 and 1000 characters.'
if (data.get('privacy') !== 'true') errors.privacy = 'Accept the privacy policy to send.'
if (Object.keys(errors).length) return fail(400, { errors })
try {
await sendMail({ to: `${topic}@northwind.io`, replyTo: email, subject: `[${topic}] ${name}`, text: message })
} catch {
return fail(502, { errors: { message: 'We could not send that. Try again in a minute.' } })
}
return { sent: true }
},
}
In the block's instance script, replace the onSubmit stand-in with a post to
the action. A failure result puts the server's messages under the fields
through setErrors(); the thrown error keeps the form and its values:
<script lang="ts">
import { deserialize } from '$app/forms'
let formEl!: HTMLFormElement
const contact = createContactForm({
onSubmit: async () => {
const res = await fetch('?/contact', {
method: 'POST',
headers: { accept: 'application/json' },
body: new FormData(formEl),
})
const result = deserialize(await res.text())
if (result.type === 'failure') contact.setErrors((result.data?.errors ?? {}) as ContactErrors)
if (result.type !== 'success') throw new Error('Message not sent')
},
})
</script>
<form bind:this={formEl} {...contact.formProps()}>...</form>
The other forms follow the same shape: onSubmit in the support-hours block,
onBook for the demo dialog in the cards block. Each
throws to keep what the user typed. Add a rate limit per IP address in the
action as well; a honeypot stops simple bots, not a person pasting into the
form.
See also
Live examples
- Centered contact form block - A heading over a contact form with SvTextInput name and email, an SvDropDownList topic, an SvTextArea with a counter and a required SvCheckBox. Send validates, shows the loading state, then a sent confirmation.
- Contact cards block - Sales, Support, Press and Partnerships cards with an icon, an email link and an SvButton. Book a demo opens an SvModal form that validates and marks the Sales card with an SvBadge.
- Contact with support hours block - A support ticket form with an SvSegmented priority beside a weekly hours table, an Open now / Opens at badge from the viewer's clock, and response times per channel that change with the priority.
Related articles
- Building a CRM Contacts Table in Svelte - How to build a CRM contacts grid with avatar cells, status badges, inline editing, named views per rep, and bulk actions using SvGrid.