Web app built for the Kentville Marlins Swim Team and carefully crafter by evanmarshall.dev: Check Out the Portfolio.
- Prismic: Check Out Prismic
- Nuxt: Check Out Nuxt
- YouTube Tutorial: Check Out the Tutorial for this Project
Â
To start a new project using this starter, run the following commands in your terminal:
pnpx @slicemachine/init@latest --repository >your-project-name< --starter nuxt-starter-prismic-minimal
cd your-project-name
pnpx @slicemachine/init@latest
When you're ready to start your project, run the following command:
pnpm run dev
To edit the content of this project, go to prismic.io/dashboard, click on the repository for this website, and start editing.
To create a page, click on the green pencil icon, then select Page.
Pages are made of Slices. You can add and rearrange Slices to your pages.
Your new page will be accessible by its URL, but it won't appear on the website automatically. To let users discover it, add it to the navigation.
If you launched this starter when you created a new repository in the Prismic Dashboard, then your repository is preconfigured with previews in development on localhost:3000.
To add a production preview, option your repository and go to Settings > Previews. Under Create a New Preview, fill in the three fields:
- a name (like Production)
- the domain where your app is running (like https://www.yoursite.com)
/api/preview
for the Preview Route
Now, go to a draft document and click the eye icon in the top-right corner.
To learn more about how to configure previews, read Preview Drafts in Nuxt in the Prismic documentation.
This website is preconfigured with Prismic. Functionality is provided by the @nuxtjs/prismic
package, which makes Prismic utilities available throughout the app. Take a look at the code to see how it's used.
There are two steps to rendering content from Prismic in your Nuxt project:
- Fetch content from the Prismic API
- Template the content
Here are some of the files in your project that you can edit:
nuxt.config.js
- Theprismic
property includes configurations for@nuxtjs/prismic
.pages/index.vue
- This is the app homepage. It queries and renders a page document with the UID (unique identifier) "home" from the Prismic API.pages/[uid].vue
- This is the page component, which queries and renders a page document from your Prismic repository based on the UID.slices/RichText/index.vue
- Each Slice has anindex.vue
file that renders the Slice component. Edit this file to customize your Slices.
These are important files that you should leave as-is:
pages/slice-simulator.vue
- Do not edit or delete this file. This file simulates your Slice components in development.slices/
- This directory contains Slice components, which are generated programmatically by Slice Machine. To customize a Slice template, you can edit the Slice'sindex.vue
file. To add Slices, delete Slices, or edit Slice models, use Slice Machine (more info below).
To put your project online, see Deploy your Nuxt App.
This project includes an application called Slice Machine, which generates models for your Custom Types and Slices. Slice Machine stores the models locally in your codebase, so you can save and version them. It also syncs your models to Prismic. To learn how to use Slice Machine, read Model Content in Nuxt.
If you change or add to your Custom Types, you'll need to update your route handling to match. To learn how to do that, read Define Paths in Nuxt.
In nuxt.config.ts
you will see that compatibilityVersion: 4
is being used as a feature flag, which means we are using Nuxt v4 features before version 4 is released. We are using Nuxt v3.
The app
directory contains all code used to build the frontend of the app whereas the server
directory contains serverside things api routes, middlewares, etc.
With Nuxt Modules you can easily enable TailwindCSS within our app using pnpx nuxi module add tailwindcss
and this will add tailwind to the nuxt.config.ts
as a module. Next we will create a tailwind.config.ts file and enter in some configs found below:
import type { Config } from 'tailwindcss'
export default <Partial<Config>>{
content: ["./app/**/*.{js,ts,vue}"]
}
To add Nuxt Fonts to the project run pnpx nuxi module add fonts
. This will add fonts to the nuxt.config.ts
as a module.
Next we go to the app
dir and create a file under assets/css/main.css
. Within this file you simply add the font family to the body:
body {
font-family: "DM Sans", sans-serif;
}
This tells Nuxt that we want to use the DM Sans font and it installs it for us as well.
Next we need to add css to the nuxt.config.ts
:
css: ["~/assets/css/main.css"]
Make sure to comment out the default font set in app/slices/RichText/index.vue
.
Note
Enter slicemachine by typing pnpm slicemachine
. This opens the slicemachine development environment where we can create and change data models while benefitting from versioning, previews, and auto-generated marks for us.
We can create three types of data models:
- Page types: For pages, blog posts, and landing pages for example. Anything that will have a URL.
- Custom Types: More abstract content collections. For example, a taxonomy collection, navigation document, and settings document.
- Slices
Let's create a settings document.
- Click custom types in slicemachine and select create.
- Select reusable type or single type.
- Select single type since we will only have one instance of this document inside prismic.
- Name the document.
- Click create.
Now we are brought to a page for settings document. There is a main tab and you can add, edit, and remove tabs. Tabs are a way to organize content for content writers.
Within the main tab we have two zones: static zone and slices.
Next, we will add some fields inside the static zone (i.e. text field or image). The fields will indicate to a content writer what to add as content.
After completing the Settings Document locally, you will see a custom type directory created with an index.json
found within a settings directory (/customtypes/settings/index.json
). The index.json
will contain everything we just configured within slicemachine as a json object that prismic understands.
To add all of the above local changes to prismic, we go to Review Changes and click Push.
First go to Create a New Page and you will se we have access to our settings page we just created. This is where we input the content for the site.
After entering the content into the settings document, we will hit save and publish.
Now we need to create a folder called layouts within the local app (/app/layouts
). Within this new directory we want to create a default.vue
. This is the default layout we want to use on every page. Default is a special keyword that indicates it will be applied on all pages of the website.
To get this content to prismic we use the prismic SDK, which can be found in nuxt config file under modules (@nuxtjs/prismic
).
Within the default.vue layout we first get the prismic object (usePrismic()
). This object is injected into nuxt app using auto imports. This allows us to get access to the prismic client to gather content.
Next we add data constant as settings key and use async data method to query our content. useAsyncData allows us to query on the server or the client based on the context the app is rendered on and we use this data to render our website. There are a lot of methods to get data from the prismic client (i.e. getAllByType, or getByID).
We want to get our settings document which is a single type so we use the getSingle
method.
Now within the template we can print the settings ({{settings}}
). Now if we go back to the local app we see settings JSON data printed, which we will use to render our header and our footer.
For now we will use it just to define our meta data. This is done by using useSeoMeta()
.
<script lang="ts" setup>
const prismic = usePrismic();
const { data: settings } = await useAsyncData(() =>
prismic.client.getSingle("settings")
);
useSeoMeta({
title: settings.value?.data.site_title,
ogTitle: settings.value?.data.site_title,
description: settings.value?.data.meta_description,
ogDescription: settings.value?.data.meta_description,
ogImage: computed(() => prismic.asImageSrc(settings.value?.data.meta_image)),
});
</script>
<template>
<div>
{{ settings }}
<slot />
</div>
</template>
The header and footer are components so they are found in /app/components/
directory. For the header just add the script section and template section. Within template just put placeholder header element for now.
We fetched settings from prismic in the default.vue
and we need to make them available to AppHeader.vue
. We do this by assigning a props to the header. We first define a prop with a typescript type. The props is defined within the <>
. Assign settings
object to Content.Settings
(settings document) from prismic. The Content imported from prismicio client is where you will find all of your prismic types. The content type we want is SettingsDocument
.
To summarize: We want a prop called settings and we want the content of the prop to be a settings document from prismic.
Now we can go back to default.vue
and add the AppHeader component with a settings prop containing the settings document.
Note
Make sure when creating the component that you add ?
symbol after settings because when using the useAsyncData
in the default.vue
it can also be undefined as well as settings document.
Remove {{settings}}
from default.vue so the JSON does not display on browser.
Make sure the SVG folder for header goes in the components
directory. Change the extension for logo to vue and wrap the contents within a <template></template>
. Now we can add <GlideLogo />
component to the header.
Note
Make sure to add aria-label="Main"
to the nav element in the header to indicate for accessibility that this is the main navigation.
Go back to slice machine (pnpm slicemachine
), click Custom Types then Settings then Show code snippets. The code snippets will show how you can render links for the header (Make sure to add settings?.
to data.navigation
).
The About, Schedules, Register Now will show up in the navigation now as these were created in Settings on page builder (Change temoplate to a list item).
<li v-for="link in settings?.data.navigation" :key="link.key">
<PrismicLink :field="link" :class="link.variant" />
</li>
Now we will create a link for the GlideLogo
.
<NuxtLink to="/">
<GlideLogo />
</NuxtLink>
Now to add mobile navigation menu. See below for code. We need to add logic for opening and closing the mobile menu (hamburger and cross icon).
Add const isOpen = ref(false)
and wrap the NuxtLink and logo in a div with display flex. Add @click="isOpen = false"
to the NuxtLink. Then add button with @click="isOpen = true"
. Then we add logic to the div containing the mobile menu for adding a class if isOpen
is available translate x to 0 width and if not then translate x to full width (:class="isOpen ? 'translate-x-0' : 'translate-x-full'"
).
Note
The will-change-transform
tailwind class provides CSS hint that this class will change and the browser will optimize transition to be as smooth as possible.
Install nuxt icons to replace the open and close.
Now to style the PrismicLink
for the nav items.
To style a different link.variant
property (i.e. Link or Button), which were designed in slicemachine under /Custom types/Settings/Navigation
, can be used to determine link or button. You can do this by making the button a dedicated component with a bunch of functionality, but in this case we will just create a class (:class="{ buttonLink: link.variant === 'Button' }"
). Because we will use this button component in several parts of the website we will make the class (buttonLink) global in the main.css
.
<!-- Mobile navigation. -->
<div
class="md:hidden fixed inset-0 z-40 flex flex-col items-end bg-gray-950 pr-4 pt-6"
>
<ul class="grid justify-items-end gap-6">
<li v-for="link in settings?.data.navigation" :key="link.key">
<PrismicLink :field="link" :class="link.variant" />
</li>
</ul>
</div>
.buttonLink {
/* Structure styles */
@apply relative inline-flex h-fit w-fit rounded-full px-4 py-2;
/* Color styles */
@apply border border-sky-100/20 bg-sky-200/10 text-sky-200 outline-none ring-teal-300;
/* After styles (Can also do with pseudo class after) */
@apply after:absolute after:inset-0 after:-z-10 after:rounded-full after:bg-teal-100 after:bg-opacity-0 after:blur-md;
/* Hover styles */
@apply transition-colors hover:border-teal-200/40 hover:text-teal-300 after:transition-all after:duration-500 after:hover:bg-opacity-15 after:animate-pulse focus:ring-2;
}
Copy the AppHeader
and remove all isOpen
code, styles, buttons, and leave nav, GlideLogo and ul. Make sure to update aria-label
to Footer.
Programmatically add a screen reader only class to the GlideLogo ({{ settings?.data.site_title }} home page
= Kentville Marlins home page).
In Slice Machine we go to Page Type (Not Custom Type). If you look at the default page created on Prismic you will see a title, which is Rich Text (Unlike the meta title in the custom type settings).
- We add a new tab to the Page Type of Page (Right beside the Main tab) and name it SEO.
- Click the + symbol and add Text and call it Meta Title.
- Click + again and add Text and call it Meta Description.
- Click + again and add Image and call it Meta Image. Also constrain the dimensions to 2400px by 1260px.
- Push the saved changes in Slice Machine to Prismic by clicking Review Changes and clicking Push.
Now if we go to the Prismic Page Builder and click Homepage we will see a tab for SEO. On the SEO tab you can add metadata.
- Click SEO tab and add in title, description, and image for the homepage.
- Click Save and Publish.
We have a problem when we reload the page in dev that the metadata is not reflected. We need to update our page types.
- Go to
/app/pages/index.vue
(Responsible for querying our page content). - Go back to Slice Machine > Page Types > Page > Click Page Snippet and grab the
useSeoMeta
snippet. - Within index.vue remove
useHead
and replace with the copieduseSeoMeta
. - Do the same as step 3, but in
/app/pages/[uid].vue
.
Now the metadata should show on the dev site.
Slices are the sections of the pages.
You will see slices in Slice Machine. There should already be one there for RichText slice which was used as part of the homepage.
- Enter Slice Machine and click on Slices.
- Click Create and enter Hero for the Slice Name.
- You will then be brought to a page with variations and fields. Click on Add a field.
- Add a Rich Text field, add Heading for the Label, select just h1 for Accept, and unselect Allow multiple paragraphs.
- Add another Rich Text field, add Body for the Label, and select p/b/i for Accept.
- Add a Link field for the CTA, add CTAs for the Label, and check Make this link repeatable.
- Add an Image field and add Image for the Label.
Usually you would review changes and publish to Prismic, but with slices you could also click Simulate. This opens up a visual editor for slices.
Note
If working in dark mode you can fix the white background on Simulate by going to /app/pages/slice-simulator.vue
and add a background prop to <SliceSimulator></SliceSimulator>
component (i.e. background="#030712"
).
You can verify changes to the Slice Machine Simulator for the Hero by going to /app/slices/Hero/index.vue
and updating the <template></template>
section with {{ slice }}
for example. This will display the JSON for this slice in the Simulator.
JavaScript Explanation
In /app/slices/Hero/index.vue
we are defining our props and our slice hero component is receiving some props from the page (i.e. slice, index (array of slice from prismic which are the breakdown of sections of the page), slices, and context (arbitrary object you can paste to the SliceZone in /app/pages/index.vue
to perform things on the slice such as storing global data)).
Templating
Looking at the design there is consistent padding/margin on x-axis for each slice.
- Create a new component called
Bounded.vue
. - Inside the template we will render a
<Component />
from vue with theas
prop or asection
by default. - In the scripts we will
defineProps
as string and optional (?
). - Render
<slot />
inside theComponent
. - Style the
Component
as centered and fixed max width. - Fix the error for "Components should always be multi-word" to eliminate conflicts. We will just bypass it this time in eslint config.
- Back in the
Hero
component we will replace the section to theBounded
component we created. - Show snippets for hero slice in slice machine and copy each snippet into the
Hero
component replacing{{ slice }}
. - Remove
PrismicRichText
for the heading and leave as justPrismicText
. Then we addwrapper="h1"
to have this set as an h1 element. - Do the same for the body text however we will use rich text later. Add
wrapper="p"
. - For the link (ctas) cut the attributes from the template and put it in the PrismicLink element. We only want to repeat the component and not the wrapper. Also wrap inside a div instead of template.
- You can rename
v-for
,key
andfield
to cta instead of link. - Create a class for the container of the image (glassContainer).
- Add the splash of colour behind hero image:
<div
class="absolute left-1/3 top-0 -z-10 h-2/3 w-2/3 bg-teal-600/50 blur-3xl md:blur-[120px] filter mix-blend-screen"
/>
<div
class="absolute left-0 top-1/3 -z-10 h-2/3 w-2/3 bg-sky-700/50 blur-3xl md:blur-[120px] filter mix-blend-screen"
/>
Animate Hero Template
First, within the hero slice we will call the onMounted
composable.
Within the onMounted
composable we will use a GSAP timeline. First create a timeline variable containing GSAP ease and power followed by tl.fromTo(".hero__heading", { scale: 0.5, opacity: 0 }, { scale: 1, opacity: 1, duration: 1.4 });
. The second animates the scale from 0.5 to 1 with a duration of 1.4. As well as a timeline animation for the remaining hero sections.
For the image glow we will create it outside the timeline. One point is about these animations is the repeat: -1
means that it will repeat infinite times.
gsap.to(".hero__glow--one", {
ease: "power2.inOut",
repeat: -1,
repeatDelay: 0,
keyframes: [
{ top: "0%", left: "33%", duration: 0 },
{ top: "33%", left: "33%", duration: 2 },
{ top: "33%", left: "0%", duration: 3 },
{ top: "0%", left: "0%", duration: 2 },
{ top: "0%", left: "33%", duration: 3 },
],
});
Now we can disable animations for accessibility. We put the following code inside the onMounted
composable:
// For accessibility.
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
if (prefersReducedMotion) {
return;
}
Animated Grid Component
Create a new component within the Glide
directory called Grid.vue
. Within the component we will first render the grid then animate it. See /app/components/Glide/Grid.vue
.
<script lang="ts" setup>
// Grid is 14 rows and 30 columns.
const grid = [14, 30];
</script>
<template>
<svg
id="glideGrid"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 935 425"
class="absolute -left-2 -top-14 -z-10"
style="mask-image: linear-gradient(black, transparent)"
>
<g class="glide-grid-group">
<!-- Vue template for the vector path. -->
<!-- The below d is rendering in absolute positions and we need to render it based on the position in the grid or i and j value and a relative path. -->
<!-- FROM -->
<!-- i.e. d="M3.19355 2.95082L5 0L0 2.95082L3.93548 4L3.19355 2.95082Z" -->
<!-- TO -->
<!-- :d="`M${(j - 1) * 32 + 5},${(i - 1) * 32 + 10}l1.806,-2.951l-5,2.951l3.936,1.049l-0.742,-1.049z`" -->
<template v-for="i in grid[0]" :key="i">
<path
v-for="j in grid[1]"
:key="j"
fill="currentColor"
opacity=".2"
class="glide-grid-item"
:d="`M${(j - 1) * 32 + 5},${(i - 1) * 32 + 10}l1.806,-2.951l-5,2.951l3.936,1.049l-0.742,-1.049z`"
/>
</template>
</g>
</svg>
</template>
And for the animations:
onMounted(() => {
// For accessibility.
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
if (prefersReducedMotion) {
gsap.set("#glide-grid", { opacity: 1 });
gsap.set(".glide-grid-item", {
opacity: 0.2,
scale: 1,
});
return;
}
// Starting point of animation.
gsap.set(".glide-grid-item", {
opacity: 0,
transformOrigin: "center",
color: "#fff",
});
gsap.set("#glide-grid", { opacity: 1 });
// Define the timeline.
const tl = gsap.timeline();
// Entrance Animation.
tl.to(".glide-grid-item", {
keyframes: [
{ opacity: 0, duration: 0 },
{
opacity: 0.4,
rotation: "+=180",
color: "#0284c7",
scale: 3,
duration: 0.6,
stagger: {
amount: 2,
grid: grid,
from: "center",
},
},
{
opacity: 0.2,
rotation: "+=180",
color: "#fff",
scale: 1,
delay: -2,
duration: 0.6,
stagger: {
amount: 3,
grid: grid,
from: "center",
},
},
],
});
// Loop Animation.
tl.to(".glide-grid-item", {
delay: 12,
repeat: -1,
repeatDelay: 12,
keyframes: [
{
opacity: 0.4,
rotation: "+=180",
color: "#0284c7",
scale: 3,
duration: 0.6,
stagger: {
amount: 2,
grid: grid,
from: "center",
},
},
{
opacity: 0.2,
rotation: "+=180",
color: "#fff",
scale: 1,
delay: -2,
duration: 0.6,
stagger: {
amount: 3,
grid: grid,
from: "center",
},
},
],
});
});
Add a screenshot to the slice manager for the hero slice.
Then before we push to Prismic we need to do something that will allow us to add it to our Page types. Go to Page types > Page > Add > Select existing > Select the hero slice > Add.
Go to slice machine and select Review changes > Push.
Now if we go to Prismic (Page builder) and click on Homepage > + we will have the option of adding the hero slice.
- Enter Slice Machine and click on Slices.
- Click Create and enter Bento for the Slice Name.
- You will then be brought to a page with variations and fields. Click on Add a field.
- Add a Rich Text field, add Heading for the Label, select h2 and italic for Accept, and unselect Allow multiple paragraphs.
- Add another Rich Text field, add Body for the Label, and select p/b/i for Accept.
- Add a Repeatable Group field for the bento boxes, and add Bento for the Label.
- Inside the Bento field we can Add a field. Add Rich Text, add Title for the Label, select h3 for Accept, and unselect Allow multiple paragraphs.
- Inside the Bento fields we add another Rich Text field, add Body for the Label, and select p/b/i for Accept.
- Inside the Bento fields we add an Image field, and add Image for the Label.
Now we need a way to specify if a Bento box is narrow or wide. This can be done by adding a new field called Boolean within the Bento box fields. Within the Boolean add Is Wide? for the Label.
We first open up the Simulator for the Bento slice. It will have placeholder content for the slice we just created in Slice Machine.
First we will select Show code snippets? in slice machine to add into the local template in /app/slices/Bento/index.vue
.
- Copy and paste each snippet inside the
<template><section>...here...</section></template>
. For the Bento items make sure you replace{{ item }}
inside the Bento code snippet with each Bento field item (All but the boolean). - Add a
:key
to the templateslice.primary.bento
(:key="item.title"
). - Change the section to our
Bounded
component. - Instead of using italics in the heading we will add a :components prop to the
/Bento/index.vue
.
Note
Components prop is powerful because it allows you to target any kind of node we have inside our rich text field and to render it differently using a vue component.
GlideText Component
- Create a new folder i n components called
RichText
and within it have a file calledGlideText.vue
. - Remove script and render emphasis element within the
<template></template>
. - Add
RichTextGlideText
to the :components="{ em: }" in the/Bento/index.vue
file. - Now the emphasis/italics styles will render the text as blue which was assigned in the
GlideText.vue
.
Heading 2 Component
- Create
Heading2.vue
component in the same folder asGlideText.vue
and remove all but the template. - Convert the
div
within the template to anh2
. - Then add
heading2: RichTextHeading2
to the:components
of the heading.