My visual Book Tracking MOC (DataviewJS + CSS)

Is this project open source? Yes
Is this project completely free? Yes
Is this project vibe-coded beyond the author’s ability to comprehend how it works? No
Community Directory: N/A (This is a workflow showcase / CSS snippet, not a plugin)


My visual Book Tracking MOC (DataviewJS + CSS) :books:

Hey everyone!

I wanted to share my visual dashboard (MOC) for tracking my books. I was looking for something highly visual for my active queue, but strictly analytical and automated for my yearly archives.

Here is what it looks like:


Features

  • Visual Grid: Shows books I am currently reading with cover images, progress bars, and custom-colored categories.
  • Automated Tables: Tracks finished books by year (e.g., “Read in 2026”, “Read in 2025”) and maintains a complete library table at the bottom.
  • 100% Dynamic: Uses dataviewjs to automatically fetch metadata from note frontmatter.

How to Set It Up

1. Create a CSS Snippet

Create a file named books.css in your .obsidian/snippets/ folder, paste the following code, and enable it in your Obsidian settings (Appearance → CSS Snippets):

.books > span {
    padding: 15px;
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
    grid-auto-rows: minmax(300px, auto);
    gap: 15px;
    height: 100%;
}

.books img {
    cursor: pointer;
    height: 250px !important;
    display: block; 
    margin: 0 auto 10px auto;
    border-radius: 8px;
    box-shadow: 0 4px 12px rgba(0,0,0,0.15);
    transition: transform 0.2s ease;
}

.books img:hover {
    transform: scale(1.03);
}

.books p[dir=auto] { margin: 0; }
.books progress { display: block !important; margin: 0 auto; }
.books .book { text-align: center; }
.books .book .pages { color: #9e9e9e; font-size: 0.75em; }

.books .book .category {
    display: inline-block;
    margin-bottom: 0.3em;
    border-radius: 3px;
    padding: 0px 7px;
    width: auto;
    font-size: 0.75em;
}

2. Create the DataviewJS Codeblock

Create a new note in Obsidian, make sure you have the Dataview plugin enabled (with Enable JavaScript Queries toggled ON), and paste the following code:

# 📖 Currently Reading (Grid)

```dataviewjs
const categoryColors = {
	"productivity": "#fff9f2", "development": "#ebffff", "business": "#f5fff7",
	"fiction": "#f7f9ff", "science": "#fffdf0",
	"default": "#f5f5f5"
};
const getCategoryColor = category => {
    if (category in categoryColors) return categoryColors[category];
    return categoryColors["default"];
}

const hasBookTag = (tags) => {
    if (!tags) return false;
    if (tags.path) return tags.path.includes("book");
    if (Array.isArray(tags)) {
        return tags.some(t => {
            if (typeof t === "string") return t.includes("book");
            if (t && t.path) return t.path.includes("book");
            return false;
        });
    }
    if (typeof tags === "string") return tags.includes("book");
    return false;
};

const getBooks = () => dv.pages("").where(page => {
    const tags = page.Tags || page.tags;
    const started = page["Date Started"];
    const finished = page["Date Finished"];
    
    const isFinished = finished && finished !== "" && finished !== null;
    
    if (hasBookTag(tags) && page.file.folder != "8 - Templates" && !isFinished && started) {
        return true;
    }
    return false;
}).sort(p => p["Date Started"], "desc");

const formatDate = d => {
    if (!d) return "";
    return d.year === dv.date("now").year
        ? d.toFormat("MMMM d")
        : d.toFormat("MMMM d, yyyy");
}

let books = "";

for (const book of getBooks()) {
	const categoriesProperty = book.Categories || book.categories;
	const categories = categoriesProperty ? categoriesProperty.map(
	    c => `<span class="category" style="background-color: ${getCategoryColor(c)}">${c}</span>`
	).join(" ") : "";
	
	const cover = book.Cover || book.cover;
	const pages = book.Pages || book.pages;
	const started = book["Date Started"];
	
    books += `<div class="book">
		<a data-tooltip-position="top" data-href="${book.file.name}" href="${book.file.name}.md" class="internal-link" target="_blank" rel="noopener nofollow"><img src="${cover}" data-filename="${book.file.name}" /></a>
        ${book.Progress || ""}
        Started: ${formatDate(started)}<br>
        <div class="categories">${categories}</div>
        <div class="pages">${pages} p.</div>
    </div>`;
}

if (books === "") {
    dv.paragraph("No books currently reading.");
} else {
    dv.el("div", books, {cls: "books"});
}

:bar_chart: Currently Reading (Table)

const hasBookTag = (tags) => {
    if (!tags) return false;
    if (tags.path) return tags.path.includes("book");
    if (Array.isArray(tags)) {
        return tags.some(t => {
            if (typeof t === "string") return t.includes("book");
            if (t && t.path) return t.path.includes("book");
            return false;
        });
    }
    if (typeof tags === "string") return tags.includes("book");
    return false;
};

let booksCount = 0;

let bookPages = dv.pages("").where(page => {
    const tags = page.Tags || page.tags;
    const started = page["Date Started"];
    const finished = page["Date Finished"];
    
    const isFinished = finished && finished !== "" && finished !== null;
    
    if (hasBookTag(tags) && page.file.folder != "8 - Templates" && !isFinished && started) {
	  booksCount++;
      return true;
    }
    return false;
}).sort(p => p["Date Started"], "desc");

if (booksCount === 0) {
    dv.paragraph("No books currently reading.");
} else {
    const MAX_BOOK_NAME_LENGTH = 50;
    let index = booksCount + 1;

    dv.table(
        ["#", "Title", "Author", "Pages", "Started", "Progress"],
        bookPages.map(p => {
            const title = p.Title || p.title;
            const author = p.Author || p.author;
            const started = p["Date Started"];
            
            const shortName = title && title.length > MAX_BOOK_NAME_LENGTH  
                ? title.substring(0, MAX_BOOK_NAME_LENGTH) + "..." 
                : title;
            const authors = author ? author.split(",").map(el => el.trim()).join("<br />") : "";
            return [
                --index,
                dv.fileLink(p.file.path, false, shortName),
                authors,
                p.Pages || p.pages,
                started !== null ? started.toFormat("MMMM d, yyyy") : "",
                p.Progress || ""
            ];
        })
    );
}

:books: Read in 2026

const hasBookTag = (tags) => {
    if (!tags) return false;
    if (tags.path) return tags.path.includes("book");
    if (Array.isArray(tags)) {
        return tags.some(t => {
            if (typeof t === "string") return t.includes("book");
            if (t && t.path) return t.path.includes("book");
            return false;
        });
    }
    if (typeof tags === "string") return tags.includes("book");
    return false;
};

let booksCount = 0;

let bookPages = dv.pages("").where(page => {
    const tags = page.Tags || page.tags;
    const finished = page["Date Finished"];
    
    if (hasBookTag(tags) && page.file.folder != "8 - Templates" && finished && new Date(finished.ts).getFullYear() === 2026) {
	  booksCount++;
      return true;
    }
    return false;
}).sort(p => p["Date Finished"], "desc");

if (booksCount === 0) {
    dv.paragraph("No books finished in 2026.");
} else {
    const MAX_BOOK_NAME_LENGTH = 50;
    let index = booksCount + 1;

    dv.table(
        ["#", "Title", "Author", "Pages", "Finished"],
        bookPages.map(p => {
            const title = p.Title || p.title;
            const author = p.Author || p.author;
            
            const shortName = title && title.length > MAX_BOOK_NAME_LENGTH  
                ? title.substring(0, MAX_BOOK_NAME_LENGTH) + "..." 
                : title;
            const authors = author ? author.split(",").map(el => el.trim()).join("<br />") : "";
            return [
                --index,
                dv.fileLink(p.file.path, false, shortName),
                authors,
                p.Pages || p.pages,
                p["Date Finished"] !== null ? p["Date Finished"].toFormat("MMMM d, yyyy") : ""
            ];
        })
    );
}

:books: Read in 2025

const hasBookTag = (tags) => {
    if (!tags) return false;
    if (tags.path) return tags.path.includes("book");
    if (Array.isArray(tags)) {
        return tags.some(t => {
            if (typeof t === "string") return t.includes("book");
            if (t && t.path) return t.path.includes("book");
            return false;
        });
    }
    if (typeof tags === "string") return tags.includes("book");
    return false;
};

let booksCount = 0;

let bookPages = dv.pages("").where(page => {
    const tags = page.Tags || page.tags;
    const finished = page["Date Finished"];
    
    if (hasBookTag(tags) && page.file.folder != "8 - Templates" && finished && new Date(finished.ts).getFullYear() === 2025) {
	  booksCount++;
      return true;
    }
    return false;
}).sort(p => p["Date Finished"], "desc");

if (booksCount === 0) {
    dv.paragraph("No books finished in 2025.");
} else {
    const MAX_BOOK_NAME_LENGTH = 50;
    let index = booksCount + 1;

    dv.table(
        ["#", "Title", "Author", "Pages", "Finished"],
        bookPages.map(p => {
            const title = p.Title || p.title;
            const author = p.Author || p.author;
            
            const shortName = title && title.length > MAX_BOOK_NAME_LENGTH  
                ? title.substring(0, MAX_BOOK_NAME_LENGTH) + "..." 
                : title;
            const authors = author ? author.split(",").map(el => el.trim()).join("<br />") : "";
            return [
                --index,
                dv.fileLink(p.file.path, false, shortName),
                authors,
                p.Pages || p.pages,
                p["Date Finished"] !== null ? p["Date Finished"].toFormat("MMMM d, yyyy") : ""
            ];
        })
    );
}

:books: All Books

const hasBookTag = (tags) => {
    if (!tags) return false;
    if (tags.path) return tags.path.includes("book");
    if (Array.isArray(tags)) {
        return tags.some(t => {
            if (typeof t === "string") return t.includes("book");
            if (t && t.path) return t.path.includes("book");
            return false;
        });
    }
    if (typeof tags === "string") return tags.includes("book");
    return false;
};

let booksCount = 0;

let bookPages = dv.pages("").where(page => {
    const tags = page.Tags || page.tags;
    
    if (hasBookTag(tags) && page.file.folder != "8 - Templates") {
	  booksCount++;
      return true;
    }
    return false;
}).sort(p => p["Date Finished"] || p["Date Started"], "desc");

if (booksCount === 0) {
    dv.paragraph("No books found.");
} else {
    const MAX_BOOK_NAME_LENGTH = 50;
    let index = booksCount + 1;

    dv.table(
        ["#", "Title", "Author", "Pages", "Status"],
        bookPages.map(p => {
            const title = p.Title || p.title;
            const author = p.Author || p.author;
            const finished = p["Date Finished"];
            
            const shortName = title && title.length > MAX_BOOK_NAME_LENGTH  
                ? title.substring(0, MAX_BOOK_NAME_LENGTH) + "..." 
                : title;
            const authors = author ? author.split(",").map(el => el.trim()).join("<br />") : "";
            
            const status = (finished && finished !== "") ? finished.toFormat("MMMM d, yyyy") : "Reading...";
            
            return [
                --index,
                dv.fileLink(p.file.path, false, shortName),
                authors,
                p.Pages || p.pages,
                status
            ];
        })
    );
}

### 3. Your Book Note Frontmatter Format
To make the DataviewJS scripts find your books, make sure your book notes contain the following frontmatter (Properties):

```yaml
---
Tags: [[3 - Tags/book]]
Title: Show Your Work!
Author: Austin Kleon
Pages: 224
Date Started: 2026-05-10
Date Finished: 
Progress: <progress value="60" max="100"></progress>
Categories:
  - productivity
  - business
Cover: https://m.media-amazon.com/images/I/71Z-A2B2I+L._AC_UF1000,1000_QL80_.jpg
---

Link to project’s repository (made inline for security): https://github.com/danzansho/obsidian-voice-bot

Let me know if you run into any issues or want to customize the design further. Happy reading and note-taking!

1 Like