18 MAY 2024

Astro + Pagefind + Solid = 🤯

Build a search UI for your Astro site using Pagefind and SolidJS

Build a search UI for your Astro site using Pagefind and SolidJS

Astro is a framework for building blazingly fast statically generated sites. It is a great choice when building content-driven websites (this site is built with Astro!). But, when it comes to searching your site, there aren’t many great picks except for Pagefind, a full-text search library written in Rust which helps it reach outstanding performance.

Pagefind is really easy to integrate with any framework, because the only requirement is, well, static site generation! Just run the following command in your terminal (assuming that your generated site files reside in ./dist):

Terminal window
yarn dlx pagefind --site dist

The above command will generate a pagefind directory in our dist folder. This directory contains everything related to Pagefind: binaries (WASM), scripts (JS), indexes. It also contains Pagefind’s modular UI library (pagefind-modular-ui.js), but we won’t need that today. Only 2 files out of this whole directory are actually in our field of interest: pagefind.js and pagefind-highlight.js. The first one is the primary Pagefind library, which we’ll need to implement searching, and the last one is the highlighting library used by Pagefind. For highlighting, skip to Highlighting.

Search UI

Now that we know the basics, it’s time to create the search UI!

Our stack

First, let’s settle on all libraries we will use for building the search UI. I haven’t included Astro in this list because the search UI will be able to be consumed outside of an Astro project.

We will use Solid as our UI framework. I also recommend you to install the following Solid Primitives, as they will greatly simplify the process: refs, media, event-listener and utils.

Setting up

Now that we know our stack, let’s install everything:

Terminal window
yarn astro add solid # Will add solid-js and @astrojs/solid-js
yarn add @solid-primitives/media @solid-primitives/refs @solid-primitives/event-listener @solid-primitives/utils
yarn add @vanilla-extract/css @vanilla-extract/recipes
yarn add -D @vanilla-extract/vite-plugin

Getting started

Dialog

Most websites implement their search UI as a modal dialog. That’s what we’ll do now.

components/search/search.tsx
1
type SearchProps = {
2
children: JSX.Element;
3
}
4
5
export const Search: Component<SearchProps> = (props) => {
6
/*
7
We use a `<div>` instead of `<>` (fragment)
8
because we dont' want our dialog to appear outside of `<astro-island>`.
9
*/
10
return (
11
<div>
12
<dialog>
13
14
</dialog>
15
</div>
16
);
17
}

Animation

You can skip this step if you don’t wish to add animation to your dialog.

Terminal window
yarn add @solid-primitives/presence
components/search/search.tsx
1
import { createPresence } from "@solid-primitives/presence";
2
3
export const Search: Component<SearchProps> = (props) => {
4
const {
5
isEntering,
6
isExiting,
7
isMounted
8
} = createPresence({
9
enterDuration: 600,
10
exitDuration: 300,
11
});
12
13
const openView = (event?: MouseEvent) => {
14
event?.preventDefault();
15
}
16
const closeView = () => {
17
18
}
19
20
createEffect<boolean>((prevMounted) => {
21
document.body.toggleAttribute("data-dialog-open", open());
22
if(isMounted() === prevMounted) return isMounted();
23
if(isMounted()) {
24
dialogRef.showModal();
25
}
26
else {
27
dialogRef.close();
28
}
29
return isMounted();
30
}, isMounted());
31
32
return (
33
<div>
34
{anchor()}
35
<dialog
36
onClick={event => {
37
const rect = event.currentTarget.getBoundingClientRect();
38
if (rect.left > event.clientX ||
39
rect.right < event.clientX ||
40
rect.top > event.clientY ||
41
rect.bottom < event.clientY
42
) closeView();
43
}}
44
onCancel={closeView}>
45
46
</dialog>
47
</div>
48
)
49
}
components/search/search.css.ts
1
import { style } from "@vanilla-extract/css";
2
import { style, keyframes } from "@vanilla-extract/css";
3
import { recipe } from "@vanilla-extract/recipes";
4
5
const viewEnter = keyframes({
6
from: {
7
opacity: 0,
8
scale: 0.35,
9
},
10
});
11
const viewExit = keyframes({
12
to: {
13
opacity: 0,
14
scale: 0.35,
15
},
16
});
17
18
export const searchViewStyle = style({
19
export const searchViewStyle = recipe({
20
base: {
21
// TODO: add base styles
22
},
23
variants: {
24
state: {
25
entering: {
26
animation: `${viewEnter} 600ms cubic-bezier(0.05, 0.7, 0.1, 1.0) forwards`,
27
},
28
exiting: {
29
animation: `${viewExit} 300ms cubic-bezier(0.3, 0.0, 0.8, 0.15) forwards`,
30
},
31
}
32
},
33
});

Adding a responsive anchor

Now that our search UI is fully working, all that’s left to do is to use it, right? Well, not quite. Remember how we must pass a search anchor to our component? Even though is anchor can be any clickable HTML element, I think it would be good for us to make a responsive search anchor.

We will have our component have 3 different states:

  • Search icon button - smallest
  • Search button with label - medium
  • Search bar - largest

If you feel like the medium state is unnecessary, you can skip it, but I will show you how to make it just to demonstrate the use of adaptive breakpoints.

Since our UI design is based on Material You, it’s a good idea to keep the style consistent and use Material’s official specifications for our anchor components:

Here are the three components:

components/search/search-bar.tsx
1
import {} from "./search-bar.css";
2
3
export const SearchBar: Component = () => {
4
return (
5
6
);
7
}
components/search/search-bar.css.ts

Search icon

components/search/search-icon.tsx
1
export const SearchIcon: Component = () => {
2
return (
3
4
);
5
}

Finally, it’s time to unite all those 3 components into one. We’ll call it SearchAnchor.

components/search/search-anchor.tsx
1
import { type Component, type JSX, Switch, Match, createMemo } from "solid-js";
2
import { createMediaQuery } from "@solid-primitives/media";
3
4
import { SearchIcon } from "./search-icon";
5
import { SearchButton } from "./search-button";
6
import { SearchBar } from "./search-bar";
7
8
export type SearchAnchorProps = {
9
variant?: "icon" | "button" | "bar";
10
}
11
12
type Breakpoint = "compact" | "medium" | "expanded";
13
14
export const SearchAnchor: Component<SearchAnchorProps> = (props) => {
15
const createMinWidth = (value: number) =>
16
createMediaQuery(`only screen and (min-width: ${value}px)`);
17
18
const isMedium = createMinWidth(600);
19
const isExpanded = createMinWidth(840);
20
const breakpoint = createMemo<Breakpoint>(() => {
21
if(isExpanded()) return "expanded";
22
if(isMedium()) return "medium";
23
return "compact";
24
});
25
26
return (
27
<Switch>
28
<Match when={breakpoint() === "compact"}>
29
<SearchIcon />
30
</Match>
31
<Match when={breakpoint() === "medium"}>
32
<SearchButton />
33
</Match>
34
<Match when={breakpoint() === "expanded"}>
35
<SearchBar />
36
</Match>
37
</Switch>
38
);
39
}

Let’s update our index.ts file to export all new components:

components/search/index.ts
1
export * from "./search";
2
export * from "./search-icon";
3
export * from "./search-button";
4
export * from "./search-bar";
5
export * from "./search-anchor";

Using the component

All that’s left to do now is just to use our component. Let’s add it to our Header component:

components/Header.astro
1
---
2
import { Search, SearchAnchor } from "./search";
3
4
interface Props {}
5
---
6
7
<header>
8
<Search client:load>
9
<SearchAnchor />
10
</Search>
11
</header>

Highlighting

Now that we have our search working, we can move on to add results highlighting!

components/Highlight.astro
1
---
2
import type { JSX } from "astro/jsx-runtime";
3
4
type Props = Omit<JSX.ScriptHTMLAttributes, "type" | "is:raw">;
5
---
6
7
<script is:inline type="module" {...Astro.props}> {/* Add `is:inline` here so the framework won't yell at us */}
8
document.addEventListener(
9
"astro:page-load",
10
async () => {
11
await import("/pagefind/pagefind-highlight.js");
12
new PagefindHighlight({ highlightParam: "highlight" });
13
}
14
);
15
</script>

The Pagefind Highlighter will add the pagefind-highlight class to all highlighted elements, so we can style them appropriately.

Then, we just need to insert the Highlight component into our page’s head element:

layouts/Page.astro
1
---
2
import Head from "../components/Head.astro";
3
import Highlight from "../components/Highlight.astro";
4
---
5
6
<html lang="en">
7
<head>
8
<Head />
9
<Highlight />
10
</head>
11
<body>
12
<slot />
13
</body>
14
</html>

Styling

If you are not happy with the default highlight styles (which are quite nice!), you can give the highlight some shiny new styles.

layouts/Page.astro
13 collapsed lines
1
---
2
import Head from "../components/Head.astro";
3
import Highlight from "../components/Highlight.astro";
4
---
5
6
<html lang="en">
7
<head>
8
<Head />
9
<Highlight />
10
</head>
11
<body>
12
<slot />
13
</body>
14
</html>
15
16
<style is:global>
17
.pagefind-highlight { }
18
</style>

Here’s a basic example of custom highlight styles:

layouts/Page.astro
17
.pagefind-highlight {
18
19
}

Going further: automatic indexing

If you want to automatically run Pagefind after building your site, you could write a simple Astro integration. First, we need to install @types/node:

Terminal window
yarn add -D @types/node
integrations/pagefind.ts
1
import type { AstroIntegration } from "astro";
2
3
export default function astroPagefind(): AstroIntegration {
4
return {
5
name: "astro-pagefind",
6
hooks: {
7
"astro:build:done": async ({ dir }) => {
8
const targetDir = fileURLToPath(dir);
9
const cwd = dirname(fileURLToPath(import.meta.url));
10
const relativeDir = relative(cwd, targetDir);
11
return new Promise<void>(resolve => {
12
spawn(
13
"yarn", ["dlx", "pagefind", "--site", relativeDir],
14
{
15
stdio: "inherit", // Needed so we can see the output of the command
16
shell: true,
17
cwd,
18
}
19
).on("close", () => resolve());
20
});
21
}
22
}
23
}
24
}

Then, add it to your Astro config:

astro.config.ts
1
import { defineConfig } from "astro/config";
2
3
import solidJs from "@astrojs/solid-js";
4
import pagefind from "./src/integrations/pagefind";
5
6
export default defineConfig({
7
integrations: [
8
solidJs(),
9
pagefind(),
10
],
11
/* ... */
12
});

And that’s it! Now Pagefind will run right after your site has been built. Try it out:

Terminal window
yarn build

../../components/primitives/button