Crafting A Minimalist Portfolio Website with SvelteKit and Pico CSS
Bagas Hizbullah
Posted on October 14, 2023
Creating a portfolio website can be a practical way to showcase resumes, projects, and blogs. As someone who leans more towards backend development, I found myself in the situation of wanting to establish an online presence without drowning myself into the complexities of React or dealing with the manual development with plain HTML, CSS, and JavaScript. It was in the search for a simpler alternatives that I found Svelte, and its meta-framework, SvelteKit.
The simplicity of Svelte's syntax, its templates, and its approach to frontend development, which relies on compilation rather than a virtual DOM, has won me over. And then there is SvelteKit, an extension of Svelte that simplifies the process of building websites, which made it a great choice for my portfolio website project. It offered a perfect balance between simplicity and powerful functionality that aligns well with my preferences.
However, the question of CSS styling remained. The vast ecosystem of Tailwind CSS, with its extensive class-based utility system and configurations, felt kind of overwhelming for my needs. On the other hand, creating styles entirely from scratch using pure CSS felt time-consuming and inefficient.
My search for a middle ground led me to Pico CSS, a minimalist CSS framework with a unique approach to styling. What makes Pico CSS different is its focus on elegant styling without the use of classes. Instead, it encourages the use of semantic HTML tags to implement predefined styles rather than abusing <div> tags with CSS classes, making it an interesting choice for those who prefer to keep their HTML clean and meaningful.
In this blog post, I will try to explain the technical aspects of how I utilized SvelteKit and Pico CSS to create a portfolio website that effectively presents my resume, projects, and blogs.
Initiate SvelteKit Project
Getting started with a SvelteKit project is a straight-forward process. The easiest way is to run npm create command:
npm create svelte@latest my-app
It will scaffold a new project in the my-app directory, asking questions to set up some basic tooling such as TypeScript. However, I'm using JSDoc for this project, because it is good enough for my needs.
SvelteKit follows conventions that encourage efficient code organization. There are two basic concepts:
Each page is a Svelte component.
Create more pages by adding files to the src/routes directory of the project.
I used the index page for my resume. Then, I created 2 specific directories, blogs and projects, along with their mandatory +page.svelte and +page.server.js files. These files served as the components that rendered the content for my /blogs and /projects pages respectively.
Additionally, I added a +error.svelte file for rendering custom error messages when needed.
<script>import{page}from'$app/stores';/** @type {Record<number, string>} map of status codes to emojis */constemojis={400:'❌',// bad request401:'🔒',// unauthorized403:'🚫',// forbidden404:'🔍',// not found500:'🛠',// internal server error502:'🔌',// bad gateway503:'🔧',// service unavailable504:'⏳'// gateway timeout};</script><sectionid="error"class="container"><h1><strong>{$page.status}</strong></h1><h2>{$page.error?.message}</h2><spanid="emoji">
{emojis[$page.status] ?? emojis[500]}
</span></section><style>h1,h2{margin-bottom:1rem;}#error{display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;margin:0rem;}#emoji{font-size:10rem;}</style>
For static data, I put them as a JSON object inside src/lib/store.js file.
/**
* Experience data for the resume.
*
* @type {Array<{
* job: string,
* url: string,
* company: string,
* start: string,
* end: string,
* description: Array<string>
* }>}
*/exportconstexperiences=[// ...];/**
* Volunteering data for the resume.
*
* @type {Array<{
* job: string,
* url: string,
* company: string,
* start: string,
* end: string,
* description: Array<string>
* }>}
*/exportconstvolunteering=[// ...];/**
* Social data for the resume.
*
* @type {Array<{
* href: string,
* rel: string,
* fa_class: string,
* }>}
*/exportconstsocials=[// ...];/**
* Education data for the resume.
*
* @type {Array<{
* school: string,
* url: string,
* major: string,
* start: string,
* end: string,
* description: string
* }>}
*/exportconsteducations=[// ...];/** Font Awesome icons name for the skills data. */exportconstskills=[// ...];/** Workflows data for the resume. */exportconstworkflows=[// ...];/**
* Awards data for the resume.
*
* @type {Array<{
* place: number,
* suffix: string,
* host: string,
* competition: string,
* translation: string
* }>}
*/exportconstawards=[// ...];/**
* Certification data for the resume.
*
* @type {Array<{
* title: string,
* credential_id: string,
* credential_url: string
* }>}
*/exportconstcertifications=[// ...];
I also put all the necessary component files inside the src/lib/components directory.
And that was basically it. I was able to use Pico CSS's elegant styles for all native HTML elements without the need for classes, enhancing the overall aesthetics of my website.
I also borrowed some code from Pico CSS's example code for a theme switcher by manipulating the data-theme attribute for the <html> tag using JavaScript. I placed the code inside src/lib/scripts/minimal-theme-switcher.js file.
/*!
* Minimal theme switcher
*
* Pico.css - https://picocss.com
* Copyright 2019-2023 - Licensed under MIT
*//**
* Minimal theme switcher
*
* @namespace
* @typedef {Object} ThemeSwitcher
* @property {string} _scheme - The current color scheme ("auto", "light", or "dark").
* @property {string} menuTarget - The selector for the menu element that contains theme switchers.
* @property {string} buttonsTarget - The selector for theme switcher buttons.
* @property {string} buttonAttribute - The attribute name used for theme switcher buttons.
* @property {string} rootAttribute - The attribute name used for the root HTML element to store the selected theme.
* @property {string} localStorageKey - The key used to store the preferred color scheme in local storage.
*/exportconstThemeSwitcher={// Config_scheme:'auto',menuTarget:"details[role='list']",buttonsTarget:'a[data-theme-switcher]',buttonAttribute:'data-theme-switcher',rootAttribute:'data-theme',localStorageKey:'picoPreferredColorScheme',/**
* Initialize the theme switcher.
*
* @function
* @memberof ThemeSwitcher
*/init(){this.scheme=this.schemeFromLocalStorage||this.preferredColorScheme;this.initSwitchers();},/**
* Get the color scheme from local storage or use the preferred color scheme.
*
* @function
* @memberof ThemeSwitcher
* @returns {string|null} The color scheme ("light", "dark", or null).
*/getschemeFromLocalStorage(){if (typeofwindow.localStorage!=='undefined'){if (window.localStorage.getItem(this.localStorageKey)!==null){returnwindow.localStorage.getItem(this.localStorageKey);}}returnthis._scheme;},/**
* Get the preferred color scheme based on user preferences.
*
* @function
* @memberof ThemeSwitcher
* @returns {string} The preferred color scheme ("light" or "dark").
*/getpreferredColorScheme(){returnwindow.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';},/**
* Initialize the theme switcher buttons and their click events.
*
* @function
* @memberof ThemeSwitcher
*/initSwitchers(){constbuttons=document.querySelectorAll(this.buttonsTarget);buttons.forEach((button)=>{button.addEventListener('click',(event)=>{event.preventDefault();// Set schemethis.scheme=button.getAttribute(this.buttonAttribute)||'auto';// Close dropdowndocument.querySelector(this.menuTarget)?.removeAttribute('open');},false);});},/**
* Set the selected color scheme and update the UI.
*
* @function
* @memberof ThemeSwitcher
* @param {string} scheme - The color scheme to set ("auto", "light", or "dark").
*/setscheme(scheme){if (scheme=='auto'){this.preferredColorScheme=='dark'?(this._scheme='dark'):(this._scheme='light');}elseif (scheme=='dark'||scheme=='light'){this._scheme=scheme;}this.applyScheme();this.schemeToLocalStorage();},/**
* Get the current color scheme.
*
* @function
* @memberof ThemeSwitcher
* @returns {string} The current color scheme ("auto", "light", or "dark").
*/getscheme(){returnthis._scheme;},/**
* Apply the selected color scheme to the HTML root element.
*
* @function
* @memberof ThemeSwitcher
*/applyScheme(){document.querySelector('html')?.setAttribute(this.rootAttribute,this.scheme);},/**
* Store the selected color scheme in local storage.
*
* @function
* @memberof ThemeSwitcher
*/schemeToLocalStorage(){if (typeofwindow.localStorage!=='undefined'){window.localStorage.setItem(this.localStorageKey,this.scheme);}}};
Then, I used it in the Navbar.svelte component within the <script> section, utilizing Svelte's onMount feature.
Utilizing Font Awesome icons in my project was easy. By integrating the Font Awesome Kit, I have access to a vast icon library that covers various categories, from social media icons to common UI elements. Even though using the kit may not be the fastest way to render icons on the web, the flexibility of the kit allows me to select and combine icons that blend seamlessly with my website's design, adding aesthetic value and a better user experience.
Fetch Blogs Data from Dev.to API
For populating my /blogs page with my published blog articles, I utilized Dev.to's API as provided in their documentation. I included a username query parameter with my username as the value:
https://dev.to/api/articles?username=bagashiz
This query allowed me to get my published blog articles from the API and integrate them into my /blogs page on my portfolio website.
To enhance user experience and website performance, I implemented a strategy where I returned the data as a streamed promise.
This approach not only ensured a smooth and responsive user experience but also provided an nice way to handle the loading of external data.
Fetch Projects Data from GitHub GraphQL API
To populate my /projects page with information about my pinned GitHub repositories, I utilized GitHub GraphQL API. This API allows for more specific and efficient data retrieval compared to traditional REST APIs.
However, fetching data from the GitHub GraphQL API involved a slightly different process. Instead of using simple GET requests, I needed to use the POST method. To specify the data I wanted to retrieve, I included a GraphQL query in the request body:
This query allowed me to request my pinned GitHub repositories, such as repository names, descriptions, stars and forks count, primary languages, and links.
Additionally, I also needed to add a bearer token to the Authorization header of the request. This token was generated using a GitHub personal access token with the necessary permissions, including access to public repositories and the ability to read all user profile data.
To make data retrieval efficient and minimize the need to request data from the API on every page load, I implemented a caching strategy using the ioredis package, which is a popular redis client for Node.js for interacting with a Redis database.
For caching strategy, I implemented the Cache-Aside pattern. This process involves fetching data from an external API, storing it in the Redis cache, and using the cached data for the next request. I also configured the cache to expire after an hour, making sure to keep it efficient and up-to-date.
constkey='blogs';// ...return{streamed:{/**
* @type {Promise<Blog[]>} blogs - Array of dev.to blogs
*/blogs:newPromise((resolve)=>{redis.get(key).then((data)=>{if (data){constblogs=JSON.parse(data);resolve(blogs);}else{consttimestamp=newDate().toISOString();console.log(`[${timestamp}] Cache miss, getting data from dev.to API`);fetch(url).then((res)=>res.json()).then((blogs)=>{redis.setex(key,3600,JSON.stringify(blogs));resolve(blogs);});}});})}};// ...
constkey='projects';// ...return{streamed:{/**
* @type {Promise<Project[]>} projects - Array of GitHub projects
*/projects:newPromise((resolve)=>{redis.get(key).then((data)=>{if (data){constprojects=JSON.parse(data);resolve(projects);}else{consttimestamp=newDate().toISOString();console.log(`[${timestamp}] Cache miss, getting data from GitHub API`);fetch(url,reqInfo).then((res)=>res.json()).then(({data})=>{constprojects=data.user.pinnedItems.nodes;redis.setex(key,3600,JSON.stringify(projects));resolve(projects);});}});})}};// ...
Trying Out New View Transitions API
The view transitions API streamlines the process of animating between two page states, which is especially useful for page transitions. This excellent blog post by Geoff Rich offered valuable insights and guidelines on implementing this new feature to my project.
I set up the new View Transitions API in my SvelteKit project using the onNavigate() function inside the +layout.svelte file. This function enabled the transitions between different page states seamlessly.
I also implemented custom transitions using CSS. These custom transitions were defined inside the app.css file.
@keyframesfade-in{from{opacity:0;}}@keyframesfade-out{to{opacity:0;}}@keyframesslide-from-right{from{transform:translateX(30px);}}@keyframesslide-to-left{to{transform:translateX(-30px);}}/**
* slide animation if prefers-reduced-motion is not set,
* else default cross-fade animation
*/@media(prefers-reduced-motion:no-preference){:root::view-transition-old(root){animation:90mscubic-bezier(0.4,0,1,1)bothfade-out,300mscubic-bezier(0.4,0,0.2,1)bothslide-to-left;}:root::view-transition-new(root){animation:210mscubic-bezier(0,0,0.2,1)90msbothfade-in,300mscubic-bezier(0.4,0,0.2,1)bothslide-from-right;}}
Deployment
With SvelteKit, deploying my portfolio website can't get any simpler. The first step is to switch from the default SvelteKit adapter to using the Node.js adapter.
Then I created a Dockerfile that specified all the necessary dependencies and configurations. I used a multi-stage build approach to reduce the size of the final image.
FROMnode:18.18.0-alpine3.18ASbuildWORKDIR /appCOPY package.json package-lock.json ./RUN npm ci
COPY . .RUN npm run build
FROMnode:18.18.0-alpine3.18ASprodUSER node:nodeWORKDIR /appCOPY --from=build --chown=node:node /app/build ./buildCOPY --from=build --chown=node:node /app/package.json ./RUN npm i --omit=dev
CMD ["node", "build"]
After that, I set up a GitHub workflow to build and publish the Docker image to the GitHub Container Registry automatically.
name:Create and publish a Docker imageon:push:branches:["main"]env:REGISTRY:ghcr.ioIMAGE_NAME:${{ github.repository }}jobs:build-and-push-image:runs-on:ubuntu-latestpermissions:contents:readpackages:writesteps:-name:Checkout repositoryuses:actions/checkout@v4.1.0-name:Log in to the Container registryuses:docker/login-action@v3.0.0with:registry:${{ env.REGISTRY }}username:${{ github.actor }}password:${{ secrets.GITHUB_TOKEN }}-name:Extract metadata (tags, labels) for Dockerid:metauses:docker/metadata-action@v5.0.0with:images:${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-name:Build and push Docker imageuses:docker/build-push-action@v5.0.0with:context:.push:truetags:${{ steps.meta.outputs.tags }}labels:${{ steps.meta.outputs.labels }}
Finally, I pulled that image and deployed it to my VPS. You can see it live now at https://bagashiz.me!
Source Code
If you're interested in exploring more about this project, you can check out the source code in my GitHub repository
Class-less personal portfolio & resume website built using Go, Templ, HTMX, and styled with Pico CSS.
Portfolio
Description
My personal portfolio website for showcasing my resume, projects, and blog posts. The site is built using Go, Templ, HTMX, and Pico CSS. The site also uses KeyDB as a drop-in replacement for Redis.
The featured GitHub projects are dynamically retrieved through the power of the GitHub GraphQL API. The blog posts are seamlessly pulled in using the Dev.to API. Additionally, KeyDB is used to cache the GitHub and Dev.to API responses for 1 hour to reduce the number of API calls. Icons are provided by Font Awesome through their kit from the CDN. I've also implemented the new View Transition API feature to enhance the user experience.
There is an old stack version of the site in the sveltekit branch. The old stack version of the site was built using SveteKit.