In this guide, we'll walk through building a dynamic user feedback form using the @perseid/form library, a powerful alternative to Formly and Superforms. You'll see how @perseid/form
makes it easy to manage form state, validation, and conditional rendering. The form we'll build will ask users to rate a service and provide feedback. Depending on the rating, it will either show a "thank you" message or prompt the user to provide additional feedback.
π Let's get started!
Step 1: Setting Up the Form Configuration
The first step is to define the form configuration. This configuration outlines how the form behaves, including the fields, steps, and the flow between them. Here, we're going to create fields for a rating and a review, with conditional logic based on the user's rating. We'll also define messages for both positive and negative feedback.
Here's the configuration code:
import { type Configuration } from "@perseid/form";
const formConfiguration: Configuration = {
// Root step-the form will start from there.
root: "feedback",
// Callback triggered on form submission.
onSubmit(data) {
alert(`Submitting the following JSON: ${JSON.stringify(data)}`);
return Promise.resolve();
},
// `fields` define the data model the form is going to deal with.
// Expect the submitted data JSON to match this schema.
fields: {
rating: {
type: "integer",
required: true,
},
review: {
type: "string",
required: true,
// Display this field only if condition is met...
condition: (inputs) =>
inputs.rating !== null && (inputs.rating as number) < 3,
},
// Type `null` means that the value of this field will not be included in submitted data.
submit: {
type: "null",
submit: true,
},
message_good: {
type: "null",
},
message_bad: {
type: "null",
},
},
// Now that fields are defined, you can organize them in a single or multiple steps,
// depending on the UI you want to build!
steps: {
feedback: {
fields: ["rating", "review", "submit"],
// Whether to submit the form at the end of this step.
submit: true,
// Next step is conditionned to previous user inputs...
nextStep: (inputs) =>
(inputs.rating as number) < 3 ? "thanks_bad" : "thanks_good",
},
thanks_good: {
fields: ["message_good"],
},
thanks_bad: {
fields: ["message_bad"],
},
},
};
In this configuration:
- The form starts at the
feedback
step. - The form contains two fields:
rating
(required) andreview
(optional unless the rating is below 3). - Based on the rating, the form navigates to either the "good" or "bad" feedback message.
- Upon form submission, a simple alert is triggered with the submitted data.
The key point to grasp here is the function of the fields
property. It defines the structure of the data that will be submitted, essentially acting as a data model. In contrast, the steps
property outlines the form's flow, determining how these fields will be presented to the user.
Step 2: Creating the Form's Svelte Components
Now that we have the configuration, it's time to build the actual UI that will render the form. Using @perseid/form/svelte
, we can create custom field components to manage user interactions for each part of the form.
Here's the core Svelte component:
<!-- The actual Svelte component, used to build the UI! -->
<script lang="ts" context="module">
import type { FormFieldProps } from "@perseid/form/svelte";
</script>
<script lang="ts">
export let path: FormFieldProps['path'];
export let type: FormFieldProps['type'];
export let value: FormFieldProps['value'];
export let Field: FormFieldProps['Field'];
export let error: FormFieldProps['error'];
export let status: FormFieldProps['status'];
export let engine: FormFieldProps['engine'];
export let fields: FormFieldProps['fields'];
export let isActive: FormFieldProps['isActive'];
export let activeStep: FormFieldProps['activeStep'];
export let isRequired: FormFieldProps['isRequired'];
export let setActiveStep: FormFieldProps['setActiveStep'];
export let useSubscription: FormFieldProps['useSubscription'];
let currentRating = 0;
$: currentValue = value as number;
$: fields, isActive, activeStep, setActiveStep, useSubscription, type, error, Field, isRequired;
const setCurrentRating = (newRating: number) => {
currentRating = newRating;
};
const handleReviewChange = (event: Event) => {
engine.userAction({ type: "input", path, data: (event.target as HTMLTextAreaElement).value })
};
</script>
<!-- Display a different element depending on the field... -->
{#if path === 'thanks_good.1.message_good'}
<div class="message">
<h1>Thanks for the feedback π₯³</h1>
<p>We are glad you enjoyed!</p>
</div>
{:else if path === 'thanks_bad.1.message_bad'}
<div class="message">
<h1>We're sorry to hear that π₯Ί</h1>
<p>We'll do better next time, promise!</p>
</div>
{:else if path === 'feedback.0.review'}
<div class={`review ${status === "error" ? "review--error" : ""}`}>
<label for="#review">Could you tell us more?</label>
<textarea
id="review"
on:change={handleReviewChange}
/>
</div>
{:else if path === 'feedback.0.rating'}
<!-- Depending on the field status, define some extra classes for styling... -->
<div
role="button"
tabindex="0"
class={`rating ${status === "error" ? "rating--error" : ""}`}
on:mouseleave={() => {
setCurrentRating(currentValue ?? 0);
}}
>
<h1>How would you rate our service?</h1>
{#each [1, 2, 3, 4, 5] as rating (rating)}
<span
role="button"
tabindex="0"
class={`rating__star ${currentRating >= rating ? "rating__star--active" : ""}`}
on:mouseenter={() => {
setCurrentRating(rating);
}}
on:keydown={() => {}}
on:click={() => {
// On click, notify the form engine about new user input.
engine.userAction({ type: "input", path, data: rating });
}}
></span>
{/each}
</div>
{:else}
<!-- path === 'feedback.0.submit' -->
<button
class="submit"
on:click={() => {
engine.userAction({ type: "input", path, data: true });
}}
>
Submit
</button>
{/if}
Here, the Field
component uses the path
prop to decide what to render:
- A rating component where users can select a star rating.
- A textarea for users to provide additional feedback.
"Thank you" messages that appear based on the rating. The form will dynamically adjust its fields and steps based on user input.
Pretty cool, right?
Step 3: Running the Application
Now that our form configuration and component are ready, let's integrate them into a basic Svelte app. Here's the code to initialize and render the form:
// Let's run the app!
// Creating Svelte root...
const container = document.querySelector("#root") as unknown as HTMLElement;
container.innerHTML = '';
new Form({
props: {
Field: Field,
configuration: formConfiguration,
},
target: container,
});
This code mounts the form to the DOM. The Form
component, which connects our configuration and Field
component, handles everything else.
Step 4: Adding Styles
Alright, we have our app logic, but if you run the code now, you'll see that it's a bit... raw π₯Έ
So, let's pimp the form by adding some styles and animations! Below is a simple stylesheet that makes it way more appealing:
// A few animations for fun...
@keyframes swipe-out {
0% {
opacity: 1;
transform: translateX(0);
}
75% {
opacity: 0;
transform: translateX(-100%);
}
100% {
opacity: 0;
transform: translateX(-100%);
}
}
@keyframes swipe-in-one {
0% {
opacity: 0;
transform: translateX(100%);
}
75% {
transform: translateX(0);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
@keyframes swipe-in-two {
0% {
opacity: 0;
transform: translateX(0);
}
75% {
transform: translateX(-100%);
}
100% {
opacity: 1;
transform: translateX(-100%);
}
}
@keyframes bubble-in {
0% {
transform: scale(0.5);
}
75% {
transform: scale(1.5);
}
100% {
transform: scale(1);
}
}
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
// Some global basic styling...
* {
box-sizing: border-box;
}
body {
margin: 0;
display: grid;
height: 100vh;
color: #aaaaaa;
align-items: center;
font-family: "Helvetica", sans-serif;
}
// And form-specific styling.
.perseid-form {
width: 100%;
margin: auto;
&__steps {
display: flex;
overflow: hidden;
}
&__step {
min-width: 100%;
padding: 1rem 3rem;
animation: 500ms ease-in-out forwards swipe-out;
&__fields {
display: grid;
row-gap: 2rem;
}
}
&__step[class*="active"]:first-child {
animation: 500ms ease-in-out forwards swipe-in-one;
}
&__step[class*="active"]:last-child:not(:first-child) {
animation: 500ms ease-in-out forwards swipe-in-two;
}
}
.submit {
border: none;
cursor: pointer;
padding: 1rem 2rem;
border-radius: 8px;
color: #fefefe;
font-size: 1.25rem;
background: #46c0b0;
justify-self: flex-end;
transition: all 250ms ease-in-out;
&:hover {
background: #4cccbb;
}
}
.rating {
position: relative;
padding: 0.25rem 0;
&__star {
cursor: pointer;
display: inline-block;
font-size: 2rem;
min-width: 2rem;
min-height: 2rem;
&::after {
content: "βͺοΈ";
}
&--active {
animation: 250ms ease-in-out forwards bubble-in;
&::after {
content: "π";
}
}
}
&[class*="error"] {
&::after {
left: 0;
bottom: -1.5rem;
color: #f13232;
position: absolute;
font-size: 0.75rem;
content: "π This field is required";
animation: 250ms ease-in-out forwards fade-in;
}
}
}
.review {
display: grid;
row-gap: 1rem;
position: relative;
animation: 250ms ease-in-out forwards fade-in;
label {
font-size: 1.25rem;
}
textarea {
resize: none;
min-height: 5rem;
border-radius: 8px;
border: 1px solid #46c0b0;
transition: all 250ms ease-in-out;
}
&[class*="error"] {
&::after {
left: 0;
bottom: -1.5rem;
color: #f13232;
position: absolute;
font-size: 0.75rem;
content: "π This field is required";
animation: 250ms ease-in-out forwards fade-in;
}
}
}
@media screen and (min-width: 30rem) {
.perseid-form {
max-width: 30rem;
}
}
And voilΓ π€©
Conclusion
Congratulations! π You've just built a dynamic user feedback form with Perseid and Svelte.
In this tutorial, we went over how to:
- Define a form configuration with conditional logic.
- Build custom Svelte components to handle user interactions.
- Render the form in your app and style it with animations and custom CSS.
Feel free to experiment with additional fields and steps to suit your use case. Have fun building awesome forms! π
- π More examples
- β Complete documentation
- π¬ Join our Discord
- π Star the project on GitHub
- β€οΈ Sponsor Perseid
Top comments (0)