Astro, Petite Vue and motion.dev for lightweight but powerful interactivity

Monday, 17 March 2025

Last week I spent some time thinking about how I could add a sprinkling of interactivity to my Astro site when submitting a book recommendation. Keeping in line with the general ethos of this site I wanted to explore keeping the site as lightweight as possible being mindful of how much js is being added to the page weight. Originally I was wondering if I could handle it with vanilla js and add the interactivity that way, but favouring simplicity and ease I decided to go with Petite Vue. Petite Vue is pretty much what it says on the tin, it was created as a lightweight subset of Vue you can quickly add to a page and add some reactivity. As of writing this post Petite Vue is 7.8kb minified and gzipped.

Note: It’s worth mentioning that I did take a look at Alpine.js, but for what I needed it felt a little too heavy :)

Setting up Petite Vue

So no Vue files involved at all here, we can use a straight up Astro file since we are essentially creating a static html component and sprinkling some js on top.

You don’t neccessarily have to do it this way but I added petite-vue as a dev dependency in my project and then added the following to the bottom of my Astro file:

<script>
  import { actions } from "astro:actions";
  import { createApp } from "petite-vue";

  const appState = {
    // initial state
    isPending: false,
    isSuccess: false,
    // using function () is important here as it allows us to use `this` in the methods
    pending: function () {
      this.isPending = true;
    },
    success: function () {
      this.isSuccess = true;
      this.isPending = false;
    },
    reset: function () {
      this.isPending = false;
      this.isSuccess = false;
    },
  };

  // only mount the app on the #book-form element instead of the whole page
  createApp(appState).mount("#book-form");

  const form = document.querySelector("form");

  form?.addEventListener("submit", async (event) => {
    event.preventDefault();

    appState.pending();

    const formData = new FormData(form);
    // we are using Astro actions to handle the form submission
    const { error } = await actions.bookRecommendation(formData);

    if (error) {
      appState.reset();

      alert(
        "There was an error sending your recommendation. Please try again."
      );
    } else {
      // add a slight delay
      await new Promise((resolve) => setTimeout(resolve, 500));

      appState.success();

      // reset the your recommendation field in the form
      form.querySelector("textarea")!.value = "";
    }
  });
</script>

The form

The form itself is pretty simple. We are using Astro actions to handle the form submission and then we are using Petite Vue to handle the interactivity. The spring here is a function from motion that allows us to create a spring effect via css linear functions on the transition.

---
import { spring } from "motion";
---

<form
  id="book-form"
  class:list={[
    "relative rounded-2xl bg-white shadow-xl border border-white p-6 flex flex-col gap-3 w-full max-w-[460px] min-w-[440px] overflow-hidden",
  ]}
>
  <div
    v-cloak
    class="absolute z-1 inset-0 bg-white flex flex-col items-center justify-center gap-1 p-6 text-center"
    :class="isSuccess ? 'opacity-100' : 'opacity-0 pointer-events-none'"
    style={{
      transition: `opacity 0.2s ease-out`,
    }}
  >
    <div
      class="flex flex-col items-center gap-1"
      :class="isSuccess ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-30'"
      style={{
        transition: `transform  ${spring(0.3, 0.3)}, opacity 0.2s ease-out`,
      }}
    >
      <h2 class="text-xl font-medium">Success!</h2>
      <p class="text-sm opacity-90">
        Thank you for your recommendation! I look forward to checking it out and
        sharing my thoughts on it :)
      </p>

      <button
        type="button"
        class="py-2 px-3 bg-zinc-900 text-white rounded-md text-sm font-medium mt-4 w-fit"
        aria-label="Close the success message"
        @click="isSuccess = false"
      >
        Close
      </button>
    </div>
  </div>

  <Label>Your name</Label>
  <Input name="name" placeholder="Full name" />

  <Label
    >Email <span class="text-sm text-zinc-600"
      >(so I can share some appreciation!)</span
    >
  </Label>
  <Input name="email" type="email" placeholder="Email" />

  <input hidden type="text" name="honeypot" value="" />

  <Label>Your recommendation</Label>
  <Textarea
    name="recommendation"
    placeholder="Share a book (I'd also love to hear why you recommend it or love it so much!)"
  />

  <button
    type="submit"
    class="w-full py-2 px-3 bg-zinc-900 text-white rounded-md text-sm font-medium mt-3 disabled:opacity-40 disabled:cursor-not-allowed"
    :disabled="isPending"
    aria-label="Submit your recommendation"
  >
    <span v-cloak v-if="isPending"> Sending... </span>
    <span v-else> Send your recommendation </span>
  </button>
</form>

<style>
  [v-cloak] {
    display: none;
  }
</style>

First of all notice the use of v-cloak? This is a directive that is used to hide an element until the Vue instance is mounted. This is important as it stops the user from seeing conditionally rendered content before the Vue instance has been initialised.

You’ll also notice some other directives in there:

  • :class - This is a directive that allows us to bind a class to an element. In this case we are binding a class based on the isSuccess state.
  • v-if - This is a directive that allows us to conditionally render an element. In this case we are conditionally rendering the Sending... text based on the isPending state.
  • v-else - This is a directive that allows us to conditionally render an element if the previous v-if directive is false.
  • @click - This is a directive that allows us to bind an event listener to an element. In this case we are binding a click event to the button and toggling the isSuccess state.

Pretty powerful stuff right! And with not a lot of js at all!

Another thing to consider if you wanted to get the size down even more is bringing the whole Petite Vue project into your project and removing any features you don’t need (if you look at the bullet points above I’m not using a lot of the features that Petite Vue offers).

I’ve added a demo of the form below, feel free to play around with it and see how it works :)

This is a demo, submissions won’t be sent.

Success!

Thank you for your recommendation! I look forward to checking it out and sharing my thoughts on it :)

I hope this has been helpful and if you have any questions or suggestions please do let me know!