Astro, Petite Vue and motion.dev for lightweight but powerful interactivity
Monday, 17 March 2025Last 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 theisSuccess
state.v-if
- This is a directive that allows us to conditionally render an element. In this case we are conditionally rendering theSending...
text based on theisPending
state.v-else
- This is a directive that allows us to conditionally render an element if the previousv-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 theisSuccess
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.
I hope this has been helpful and if you have any questions or suggestions please do let me know!