---
description: "Read tags from YAML front matter and filter the note list with a drop-down."
---

# Filter by Tag

Read tags from each note's YAML front matter and filter the list by tag.

## Parse front matter

Tags live in the note file itself, in a block of YAML between two `---` lines at the top, so the notes folder stays plain Markdown that any editor can open. Install [yaml](https://eemeli.org/yaml/) to parse the block; it ships its own types:

```sh
npm install yaml
```

`src/notes/note.ts` splits each file into its front matter and its body:

```ts [src/notes/note.ts]
import type { Nodes, PhrasingContent } from "mdast";
import { fromMarkdown } from "mdast-util-from-markdown";
import { parse } from "yaml";

type Note = {
    name: string;
    text: string;
    modified: number;
    body: string;
    tags: string[];
    excerpt: string;
};

const FRONT_MATTER = /^---\r?\n(?:([\s\S]*?)\r?\n)?---[ \t]*(?:\r?\n|$)/;

const parseYaml = (source: string): unknown => {
    try {
        return parse(source);
    } catch {
        return null;
    }
};

const toTags = (value: unknown): string[] => {
    const values: unknown[] = Array.isArray(value) ? value : [value];
    const tags = values
        .filter((tag) => typeof tag === "string")
        .map((tag) => tag.trim().replace(/^#/, ""))
        .filter((tag) => tag !== "");

    return [...new Set(tags)];
};

const splitFrontMatter = (text: string): { tags: string[]; body: string } => {
    const match = FRONT_MATTER.exec(text);
    if (match === null) {
        return { tags: [], body: text };
    }
    const data = parseYaml(match[1] ?? "");
    const tags =
        typeof data === "object" && data !== null && "tags" in data
            ? toTags(data.tags)
            : [];

    return { tags, body: text.slice(match[0].length) };
};

const plainText = (nodes: PhrasingContent[]): string =>
    nodes
        .map((node) => {
            switch (node.type) {
                case "text":
                case "inlineCode":
                case "html":
                    return node.value;
                case "image":
                case "imageReference":
                    return node.alt ?? "";
                case "break":
                    return " ";
                case "footnoteReference":
                    return "";
                default:
                    return plainText(node.children);
            }
        })
        .join("");

const firstParagraph = (node: Nodes): string | null => {
    if (node.type === "paragraph") {
        return plainText(node.children);
    }
    if (!("children" in node) || node.type === "heading") {
        return null;
    }
    for (const child of node.children) {
        const text = firstParagraph(child);
        if (text !== null) {
            return text;
        }
    }

    return null;
};

const toNote = (name: string, text: string, modified: number): Note => {
    const { tags, body } = splitFrontMatter(text);

    return {
        name,
        text,
        modified,
        body,
        tags,
        excerpt: firstParagraph(fromMarkdown(body)) ?? "",
    };
};

export { type Note, toNote };
```

- `FRONT_MATTER` matches only at the very start of the file: a `---` line, the YAML, and a closing `---` line. A file without the block has no tags, and its body is the whole text.
- `parse` throws on YAML it can't read. `parseYaml` turns the error into `null`, so a note with a broken block still loads, with no tags.
- `toTags` accepts a list (`tags: [home, reading]`) or a single tag (`tags: home`), removes duplicates, and skips values that aren't strings, such as `2026`, which YAML reads as a number. It also drops a leading `#`, so `home` and `#home` are the same tag. A `#` after a space starts a YAML comment, so a tag written with one must be quoted: `tags: "#home"`.
- `body` is the text after the block. The excerpt and the preview read it, while the editor, the saver and the search keep using the whole `text`.

## Filter the list

`src/components/notes-screen.tsx` adds a drop-down of tags under the search entry:

```tsx [src/components/notes-screen.tsx]
import { DropDown, ListView } from "@gtkx/components";
import * as Gtk from "@gtkx/gi/gtk";
import * as Pango from "@gtkx/gi/pango";
import { useTranslation } from "@gtkx/i18n";
import { AdwSpinner, AdwStatusPage } from "@gtkx/jsx/adw";
import {
    GtkBox,
    GtkCallbackAction,
    GtkLabel,
    GtkScrolledWindow,
    GtkSearchEntry,
    GtkShortcut,
    GtkShortcutController,
    GtkShortcutTrigger,
} from "@gtkx/jsx/gtk";
import type { SplitViewScreenProps } from "@gtkx/navigation";
import { useState } from "react";
import { type RootParamList, useOpenNoteName } from "../navigation.js";
import type { Note } from "../notes/note.js";
import { useNotes } from "../notes/notes-provider.js";
import { useNoteSearch } from "../search/use-note-search.js";

const ALL_NOTES = "";

const allTags = (notes: Iterable<Note>): string[] =>
    [...new Set([...notes].flatMap((note) => note.tags))].sort((a, b) =>
        a.localeCompare(b),
    );

const TagFilter = ({
    tags,
    tag,
    onChange,
}: {
    tags: string[];
    tag: string;
    onChange: (tag: string) => void;
}) => {
    const { t } = useTranslation();

    return (
        <DropDown
            tooltipText={t("Filter by Tag")}
            items={[
                { id: ALL_NOTES, value: t("All Notes") },
                ...tags.map((name) => ({ id: name, value: `#${name}` })),
            ]}
            selectedId={tag}
            onSelectionChanged={(id) => onChange(id ?? ALL_NOTES)}
        />
    );
};

const NoteRow = ({ note }: { note: Note }) => (
    <GtkBox
        orientation={Gtk.Orientation.VERTICAL}
        spacing={2}
        marginTop={8}
        marginBottom={8}
    >
        <GtkLabel
            label={note.name}
            xalign={0}
            ellipsize={Pango.EllipsizeMode.END}
            cssClasses={["heading"]}
        />
        <GtkLabel
            label={note.excerpt}
            xalign={0}
            ellipsize={Pango.EllipsizeMode.END}
            singleLineMode
            cssClasses={["dimmed", "caption"]}
        />
    </GtkBox>
);

const FocusShortcut = ({ target }: { target: Gtk.Widget | null }) => (
    <GtkShortcutController
        scope={Gtk.ShortcutScope.GLOBAL}
        shortcuts={
            <GtkShortcut
                trigger={<GtkShortcutTrigger accelerator="<Control>f" />}
                action={
                    <GtkCallbackAction
                        callback={() => target?.grabFocus() ?? false}
                    />
                }
            />
        }
    />
);

const NotesScreen = ({
    navigation,
}: SplitViewScreenProps<RootParamList, "Notes">) => {
    const { t } = useTranslation();
    const { notes } = useNotes();
    const openName = useOpenNoteName();
    const [query, setQuery] = useState("");
    const [selectedTag, setSelectedTag] = useState(ALL_NOTES);
    const [searchEntry, setSearchEntry] = useState<Gtk.SearchEntry | null>(
        null,
    );
    const matches = useNoteSearch(notes, query.trim());

    if (notes === null) {
        return <AdwSpinner />;
    }

    if (notes.size === 0) {
        return (
            <AdwStatusPage
                iconName="document-new-symbolic"
                title={t("No Notes")}
                description={t("Notes are Markdown files in your notes folder")}
                cssClasses={["compact"]}
            />
        );
    }

    const tags = allTags(notes.values());
    const tag = tags.includes(selectedTag) ? selectedTag : ALL_NOTES;
    const shown = [...notes.values()]
        .filter((note) => tag === ALL_NOTES || note.tags.includes(tag))
        .filter((note) => matches === null || matches.has(note.name))
        .sort((a, b) => b.modified - a.modified);

    return (
        <GtkBox
            orientation={Gtk.Orientation.VERTICAL}
            controllers={<FocusShortcut target={searchEntry} />}
        >
            <GtkBox
                orientation={Gtk.Orientation.VERTICAL}
                spacing={6}
                marginTop={6}
                marginBottom={6}
                marginStart={6}
                marginEnd={6}
            >
                <GtkSearchEntry
                    ref={setSearchEntry}
                    accessibleLabel={t("Search Notes")}
                    placeholderText={t("Search notes")}
                    text={query}
                    onSearchChanged={(entry) => setQuery(entry.getText())}
                    onStopSearch={() => setQuery("")}
                />
                {tags.length > 0 && (
                    <TagFilter
                        tags={tags}
                        tag={tag}
                        onChange={setSelectedTag}
                    />
                )}
            </GtkBox>
            {shown.length === 0 ? (
                <AdwStatusPage
                    iconName="edit-find-symbolic"
                    title={t("No Results Found")}
                    vexpand
                    cssClasses={["compact"]}
                />
            ) : (
                <GtkScrolledWindow
                    vexpand
                    hscrollbarPolicy={Gtk.PolicyType.NEVER}
                >
                    <ListView<Note>
                        items={shown.map((note) => ({
                            id: note.name,
                            value: note,
                        }))}
                        isFlat
                        cssClasses={["navigation-sidebar"]}
                        selectionMode={Gtk.SelectionMode.SINGLE}
                        selectedIds={openName === null ? [] : [openName]}
                        onSelectionChanged={([name]) => {
                            if (name !== undefined) {
                                navigation.navigate("Note", { name });
                            }
                        }}
                        rowProps={({ item }) => ({
                            accessibleLabel: item.name,
                        })}
                        renderItem={({ item }) => <NoteRow note={item} />}
                    />
                </GtkScrolledWindow>
            )}
        </GtkBox>
    );
};

export { NotesScreen };
```

- `allTags` collects the tags of every note once, sorted alphabetically.
- `TagFilter` renders a [`DropDown`](/v2/guide/components#dropdown) from `@gtkx/components`. Its items are "All Notes" followed by each tag. The values are strings, so the drop-down shows them as labels without a `renderItem`. Selection works by item ID, and "All Notes" takes the empty string, which no tag can be because `toTags` drops empty ones. The tooltip says what the drop-down is for, since it shows only the current choice.
- `selectedTag` is what the user chose, and `tag` is what the list uses. When the chosen tag no longer exists, for example after you edit it out of the last note that had it, `tag` falls back to all notes, and passing it as `selectedId` moves the drop-down back to "All Notes".
- The tag and the search apply together: a note shows when it has the tag and matches the query. The search indexes each note's whole text, front matter included, so searching for a tag name also finds its notes.
- The drop-down renders only when some note has a tag, so a folder without front matter looks as before. The search entry and the drop-down share a box, which takes over the entry's margins.

## Show tags in the preview

Until now the preview rendered the whole file, so a front matter block showed as a horizontal rule above a heading made of the YAML. The note screen now passes the preview the note's body and its tags:

```tsx [src/components/note-screen.tsx]
import * as Gtk from "@gtkx/gi/gtk";
import { useTranslation } from "@gtkx/i18n";
import { AdwStatusPage } from "@gtkx/jsx/adw";
import { GtkStack, GtkStackPage } from "@gtkx/jsx/gtk";
import type { SplitViewScreenProps } from "@gtkx/navigation";
import { NoteEditor } from "../editor/note-editor.js";
import type { RootParamList } from "../navigation.js";
import { linkedNoteName } from "../notes/files.js";
import { useNotes } from "../notes/notes-provider.js";
import { MarkdownPreview } from "../preview/markdown-preview.js";

type NoteScreenProps = SplitViewScreenProps<RootParamList, "Note"> & {
    preview: boolean;
};

const NoteScreen = ({ route, navigation, preview }: NoteScreenProps) => {
    const { t } = useTranslation();
    const { notes, edit } = useNotes();
    const { name } = route.params;
    const note = notes?.get(name);

    const openLinkedNote = (uri: string): boolean => {
        const linked = linkedNoteName(uri);
        if (linked === null) {
            return false;
        }
        navigation.navigate("Note", { name: linked });

        return true;
    };

    if (note === undefined) {
        return (
            <AdwStatusPage
                iconName="edit-find-symbolic"
                title={t("Note Not Found")}
            />
        );
    }

    return (
        <GtkStack
            visibleChildName={preview ? "preview" : "editor"}
            transitionType={Gtk.StackTransitionType.CROSSFADE}
        >
            <GtkStackPage name="editor">
                <NoteEditor
                    key={name}
                    text={note.text}
                    onChange={(text) => edit(name, text)}
                />
            </GtkStackPage>
            <GtkStackPage name="preview">
                <MarkdownPreview
                    text={note.body}
                    tags={note.tags}
                    onActivateLink={openLinkedNote}
                />
            </GtkStackPage>
        </GtkStack>
    );
};

export { NoteScreen };
```

The editor still gets `note.text`, so the front matter stays editable.

`MarkdownPreview`, in `src/preview/markdown-preview.tsx`, shows the tags above the body:

```tsx [src/preview/markdown-preview.tsx]
import * as Gtk from "@gtkx/gi/gtk";
import * as Pango from "@gtkx/gi/pango";
import { useTranslation } from "@gtkx/i18n";
import { AdwClamp } from "@gtkx/jsx/adw";
import {
    GtkBox,
    GtkLabel,
    GtkScrolledWindow,
    GtkSeparator,
} from "@gtkx/jsx/gtk";
import type {
    Blockquote,
    Code,
    Heading,
    List,
    Paragraph,
    RootContent,
} from "mdast";
import { fromMarkdown } from "mdast-util-from-markdown";
import { createContext, use, useMemo } from "react";
import { toMarkup } from "./markup.js";

const LinkContext = createContext<(uri: string) => boolean>(() => false);

const HEADING_CLASSES = [
    "title-1",
    "title-2",
    "title-3",
    "title-4",
    "heading",
    "heading",
];

type TextBlockProps = {
    markup: string;
    cssClasses?: string[];
    accessibleRole?: Gtk.AccessibleRole;
};

const TextBlock = ({
    markup,
    cssClasses = [],
    accessibleRole = Gtk.AccessibleRole.LABEL,
}: TextBlockProps) => {
    const onLink = use(LinkContext);

    return (
        <GtkLabel
            accessibleRole={accessibleRole}
            label={markup}
            useMarkup
            wrap
            wrapMode={Pango.WrapMode.WORD_CHAR}
            xalign={0}
            cssClasses={cssClasses}
            onActivateLink={(uri) => onLink(uri)}
        />
    );
};

const HeadingBlock = ({ node }: { node: Heading }) => (
    <TextBlock
        accessibleRole={Gtk.AccessibleRole.HEADING}
        markup={toMarkup(node.children)}
        cssClasses={[HEADING_CLASSES[node.depth - 1] ?? "heading"]}
    />
);

const ParagraphBlock = ({ node }: { node: Paragraph }) => (
    <TextBlock markup={toMarkup(node.children)} />
);

const CodeBlock = ({ node }: { node: Code }) => (
    <GtkBox cssClasses={["card"]}>
        <GtkLabel
            label={node.value}
            selectable
            wrap
            wrapMode={Pango.WrapMode.WORD_CHAR}
            xalign={0}
            hexpand
            marginTop={12}
            marginBottom={12}
            marginStart={12}
            marginEnd={12}
            cssClasses={["monospace"]}
        />
    </GtkBox>
);

const QuoteBlock = ({ node }: { node: Blockquote }) => (
    <GtkBox spacing={12}>
        <GtkSeparator orientation={Gtk.Orientation.VERTICAL} />
        <GtkBox
            orientation={Gtk.Orientation.VERTICAL}
            spacing={12}
            hexpand
            cssClasses={["dimmed"]}
        >
            <Blocks nodes={node.children} />
        </GtkBox>
    </GtkBox>
);

const ListBlock = ({ node }: { node: List }) => (
    <GtkBox
        orientation={Gtk.Orientation.VERTICAL}
        spacing={node.spread ? 12 : 6}
    >
        {node.children.map((item, index) => (
            <GtkBox key={index} spacing={8}>
                <GtkLabel
                    label={node.ordered ? `${(node.start ?? 1) + index}.` : "•"}
                    valign={Gtk.Align.START}
                    widthChars={2}
                    xalign={1}
                />
                <GtkBox
                    orientation={Gtk.Orientation.VERTICAL}
                    spacing={6}
                    hexpand
                >
                    <Blocks nodes={item.children} />
                </GtkBox>
            </GtkBox>
        ))}
    </GtkBox>
);

const Block = ({ node }: { node: RootContent }) => {
    switch (node.type) {
        case "heading":
            return <HeadingBlock node={node} />;
        case "paragraph":
            return <ParagraphBlock node={node} />;
        case "code":
            return <CodeBlock node={node} />;
        case "blockquote":
            return <QuoteBlock node={node} />;
        case "list":
            return <ListBlock node={node} />;
        case "thematicBreak":
            return <GtkSeparator />;
        case "html":
            return (
                <TextBlock
                    markup={toMarkup([node])}
                    cssClasses={["monospace", "dimmed"]}
                />
            );
        default:
            return null;
    }
};

const Blocks = ({ nodes }: { nodes: RootContent[] }) =>
    nodes.map((node, index) => <Block key={index} node={node} />);

const Tags = ({ tags }: { tags: string[] }) => (
    <GtkBox spacing={12}>
        {tags.map((tag) => (
            <GtkLabel
                key={tag}
                label={`#${tag}`}
                cssClasses={["caption-heading", "accent"]}
            />
        ))}
    </GtkBox>
);

type MarkdownPreviewProps = {
    text: string;
    tags?: string[];
    onActivateLink?: (uri: string) => boolean;
};

const MarkdownPreview = ({
    text,
    tags = [],
    onActivateLink = () => false,
}: MarkdownPreviewProps) => {
    const { t } = useTranslation();
    const tree = useMemo(() => fromMarkdown(text), [text]);

    return (
        <GtkScrolledWindow vexpand hscrollbarPolicy={Gtk.PolicyType.NEVER}>
            <AdwClamp maximumSize={720}>
                <GtkBox
                    accessibleRole={Gtk.AccessibleRole.DOCUMENT}
                    accessibleLabel={t("Preview")}
                    orientation={Gtk.Orientation.VERTICAL}
                    spacing={12}
                    marginTop={18}
                    marginBottom={18}
                    marginStart={18}
                    marginEnd={18}
                >
                    {tags.length > 0 && <Tags tags={tags} />}
                    <LinkContext value={onActivateLink}>
                        <Blocks nodes={tree.children} />
                    </LinkContext>
                </GtkBox>
            </AdwClamp>
        </GtkScrolledWindow>
    );
};

export { MarkdownPreview };
```

`Tags` renders a row of labels with libadwaita's `caption-heading` and `accent` [style classes](https://gnome.pages.gitlab.gnome.org/libadwaita/doc/1-latest/style-classes.html). The `tags` prop is optional and defaults to an empty list, which renders no row.

## Run it

Add front matter to a few notes. Give `Book club.md` a list:

```md
---
tags: [home, reading]
---
# Book club

Next up: *Piranesi* by Susanna Clarke.
```

and put a block with a single tag at the top of `Recipes.md`:

```md
---
tags: home
---
```

The drop-down appears under the search entry. Choose **#home**, and the list narrows to Book club and Recipes. Open Book club and switch to the preview: its tags show above the heading, and the front matter doesn't.

<figure class="result">
    <img class="result-light" src="/figures/tutorial-tags-and-front-matter-light.png" width="720" height="480" loading="lazy" alt="The Notes window with #home chosen in the tag drop-down, the sidebar listing Book club and Recipes, and Book club's preview showing #home and #reading above its heading." />
    <img class="result-dark" src="/figures/tutorial-tags-and-front-matter-dark.png" width="720" height="480" loading="lazy" alt="The Notes window with #home chosen in the tag drop-down, the sidebar listing Book club and Recipes, and Book club's preview showing #home and #reading above its heading." />
    <figcaption>Captured from <a href="#filter-the-list"><code>src/components/notes-screen.tsx</code></a> and <a href="#show-tags-in-the-preview"><code>src/preview/markdown-preview.tsx</code></a> above, with #home chosen and Book club open in the preview.</figcaption>
</figure>
