DEV Community

Cover image for Building a User Feedback Form with Svelte and Perseid
Matt
Matt

Posted on

Building a User Feedback Form with Svelte and Perseid

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"],
    },
  },
};

Enter fullscreen mode Exit fullscreen mode

In this configuration:

  • The form starts at the feedback step.
  • The form contains two fields: rating (required) and review (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}
Enter fullscreen mode Exit fullscreen mode

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?

Almost there!


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,
});
Enter fullscreen mode Exit fullscreen mode

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 πŸ₯Έ

Not much to see here

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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! 🌠


Top comments (0)