logo

How Colanode ships 8K emojis and icons that work fully offline

Sep 25, 2025
Hakan Shehu
Hakan Shehu
How Colanode ships 8K emojis and icons that work fully offline

Colanode is an open-source and local-first collaboration workspace you can self-host. It provides features such as chat, pages, folders, databases, and files into one place. For messages, we wanted expressive reactions powered by a wide range of emojis. For other nodes such as pages, records, folders etc. we wanted a wide range of icons and logos so users can label and personalize their workspaces.

Because Colanode is local-first, these assets must be available without the internet, load instantly on client apps (web, desktop and mobile in the future) and be fully searchable. That turned out to be an interesting challenge.

Building the emoji and icon sets

The first step was deciding where our emojis and icons would come from.

For emojis, we found a great starting point in emoji-mart, an open-source React emoji picker. While we couldn’t use the picker itself since we needed everything offline and wanted to design a custom UI, it gave us something even more valuable: metadata. Emoji-mart maintains a detailed dataset of nearly all standard emojis, including their names, keywords, codes, and skin variations. This became the foundation of our emoji library.

Next came the actual images. We wanted scalable SVGs for crisp rendering at any size. Twitter’s twemoji project has long been a go-to for emoji graphics, but it hasn’t been updated in years. Fortunately, there is an actively maintained fork, jdecked/twemoji, which includes newer emojis. We used this fork to get the full SVG set and connected it with the emoji-mart metadata.

Icons required a different approach. We wanted two categories:

  • General icons that users could assign to pages, folders, or databases.
  • Brand logos for situations like CRM records, where a recognizable company mark is useful.

We also needed sets that contain metadata (such as categories, keywords or tags) that we can use for searching them in UI. For the general set, we chose Remix Icon, which provides a large collection of clean SVG icons. For logos, we relied on Simple Icons, which maintains SVGs for thousands of popular brands.

Bundling emojis and icons

Once we had the raw assets (emojis with metadata and SVGs, plus icons and logos) we were staring at almost 8,000 files. Having all of these files in the repository is not a developer experience we like. We needed a better way to bundle, index, and ship them so they’d be offline-ready and searchable. We built two Node.js scripts, one for emojis and one for icons that process the files and prepare them for use in Colanode. These scripts are also used whenever we need to update the sets in the future.

Since Colanode already uses SQLite on user devices to store all the data locally, it was a natural fit to use them for this case as well. We created two small SQLite databases, one for emojis and one for icons where all the metadata is stored. This includes details like emoji names, keywords, categories, and skin variations, as well as icon names and tags. SQLite also comes with a full-text search extension, which makes it possible to quickly search through thousands of emojis and icons in the UI.

Each asset is assigned a unique Colanode ID for consistency. Emojis are matched to their metadata by code, while icons are matched by filename. The scripts take care of creating the database tables on the first run and reusing existing metadata when updates are made.

The process looks like this (a similar process is used for icons):

  • Download the emoji-mart repository from GitHub
  • Download the tweemoji repository from GitHub
  • Extract the files into a temporary working directory
  • Initialize the SQLite database and tables
  • Process each emoji or icon, generate its ID, build the metadata, and store it in SQLite
  • Clean up the temporary directory

For the actual graphics, we generate two sprite files: one that contains all emojis and another that contains all icons. We use the svg-sprite library to combine everything into a single optimized SVG file.

This makes rendering very straightforward. In the app, we simply reference the ID of the emoji or icon inside the sprite. Here are the React components we use:

// emoji-element.tsx
interface EmojiElementProps {
  id: string;
  className?: string;
  onClick?: () => void;
}

export const EmojiElement = ({ id, className, onClick }: EmojiElementProps) => {
  return (
    <div className={cn("emoji-element", className)} onClick={onClick}>
      <svg>
        <use href={`/assets/emojis.svg#${id}`} />
      </svg>
    </div>
  );
};
// icon-element.tsx
interface IconElementProps {
  id: string;
  className?: string;
}

export const IconElement = ({ id, className }: IconElementProps) => {
  return (
    <div className={cn("icon-element", className)}>
      <svg fill="currentColor" viewBox="0 0 24 24">
        <use href={`/assets/icons.svg#${id}`} />
      </svg>
    </div>
  );
};

In the end, all of this work produces just four files:

  • emojis.db → SQLite database with emoji metadata
  • emojis.svg → a single sprite with all emoji graphics
  • icons.db → SQLite database with icon metadata
  • icons.svg → a single sprite with all icon graphics

Desktop app

The solution with SQLite metadata and SVG sprites works perfectly for the web. But when we moved to desktop, things got more complicated. Colanode’s desktop app is built with Electron, and due to security restrictions, you cannot directly load SVG sprites in that environment. We needed another approach to load SVG graphics locally.

It turns out that for small files, SQLite can actually be faster than the filesystem itself (read more here). That’s ideal for our case, since each emoji or icon is just a tiny SVG file. So we extended our processing scripts: in addition to generating the metadata databases, they also build a second set of databases that store the raw SVG files directly as binary blobs.

For example, here’s the table we use for storing icon SVGs:

const CREATE_ICON_SVGS_TABLE_SQL = `
  CREATE TABLE IF NOT EXISTS icon_svgs (
    id TEXT PRIMARY KEY,
    svg BLOB NOT NULL
  );
`;

During processing, each emoji or icon is written both into the sprite file and into the svgs table of the corresponding database. This leaves us with six files in total:

  • emojis.db → emoji metadata + SVGs
  • emojis.min.db → emoji metadata only
  • emojis.svg → sprite with all emojis
  • icons.db → icon metadata + SVGs
  • icons.min.db → icon metadata only
  • icons.svg → sprite with all icons

On the web client, we ship the lightweight combination: metadata databases + sprite files. On the desktop app, we ship only the full databases, with both metadata and SVGs bundled together.

To load the images on desktop, we introduced a custom local:// protocol in Electron’s main process. This protocol lets the app fetch assets directly from the SQLite database. In React, our components simply switch between using the sprite (on web) and the custom protocol (on desktop):

// emoji-element.tsx
interface EmojiElementProps {
  id: string;
  className?: string;
  onClick?: () => void;
}

export const EmojiElement = ({ id, className, onClick }: EmojiElementProps) => {
  const app = useApp();

  if (app.type === "web") {
    return (
      <svg className={className} onClick={onClick}>
        <use href={`/assets/emojis.svg#${id}`} />
      </svg>
    );
  }

  return (
    <img
      src={`local://emojis/${id}`}
      className={className}
      onClick={onClick}
      alt={id}
    />
  );
};
// icon-element.tsx
interface IconElementProps {
  id: string;
  className?: string;
}

export const IconElement = ({ id, className }: IconElementProps) => {
  const app = useApp();

  if (app.type === "web") {
    return (
      <svg className={className}>
        <use href={`/assets/icons.svg#${id}`} />
      </svg>
    );
  }

  return <img src={`local://icons/${id}`} className={className} />;
};

In the Electron main process, we register a custom local:// protocol. Whenever the app requests something like local://emojis/123, the handler looks up the matching SVG in SQLite and returns it as an image. Here’s a simplified version of the code:

// main.ts
protocol.handle("local", async (request) => {
  const [type, id] = request.url.replace("local://", "").split("/");

  if (type === "emojis") {
    const emoji = await db
      .selectFrom("emoji_svgs")
      .select("svg")
      .where("id", "=", id)
      .executeTakeFirst();

    if (emoji) {
      return new Response(emoji.svg, {
        headers: { "Content-Type": "image/svg+xml" },
      });
    }
  }

  if (type === "icons") {
    const icon = await db
      .selectFrom("icon_svgs")
      .select("svg")
      .where("id", "=", id)
      .executeTakeFirst();

    if (icon) {
      return new Response(icon.svg, {
        headers: { "Content-Type": "image/svg+xml" },
      });
    }
  }

  return new Response(null, { status: 404 });
});

This way, both the web and desktop apps get the same user experience: thousands of emojis and icons, always offline, always searchable, and always instant to load.

Themes

Everything worked smoothly—until we introduced dark mode. The problem was with icons: their color needed to adapt to the theme (black in light mode, white in dark mode). Emojis weren’t affected since they have their own colors, but icons should follow the text color automatically.

On the web this was easy. Because icons were rendered from the SVG sprite, the fill="currentColor" attribute allowed Tailwind’s text color variables to cascade down to the icons. Switching themes just worked. But on desktop, things were different. As shown earlier, we loaded icons with the <img> tag via the custom local:// protocol. CSS styling doesn’t apply to images, which meant the icons stayed fixed in color.

After experimenting with several approaches, we settled on loading the raw SVG markup directly as a string in the desktop app. This way, the SVG becomes part of the DOM and inherits CSS styles as expected. Here’s the updated icon component:

interface IconElementProps {
  id: string;
  className?: string;
}

const IconElementWeb = ({ id, className }: IconElementProps) => {
  return (
    <div className={cn("icon-element", className)}>
      <svg fill="currentColor" viewBox="0 0 24 24">
        <use href={`/assets/icons.svg#${id}`} />
      </svg>
    </div>
  );
};

const IconElementDesktop = ({ id, className }: IconElementProps) => {
  const svgQuery = useQuery({
    type: "icon.svg.get",
    id,
  });

  if (svgQuery.isLoading) {
    return null;
  }

  const svg = svgQuery.data;
  if (!svg) {
    return (
      <div className={cn("icon-element", className)}>
        <ShieldQuestionMark />
      </div>
    );
  }

  return (
    <div
      className={cn("icon-element", className)}
      dangerouslySetInnerHTML={{ __html: svg }}
    />
  );
};

export const IconElement = ({ id, className }: IconElementProps) => {
  const app = useApp();

  if (app.type === "web") {
    return <IconElementWeb id={id} className={className} />;
  }

  return <IconElementDesktop id={id} className={className} />;
};

In this version, the icon.svg.get query fetches the raw SVG string directly from SQLite and injects it into the DOM, allowing Tailwind classes and theme variables to style it properly.

There was one more challenge. This approach worked fine for the Remix Icon set, but not for the Simple Icons set. Many of those SVGs were missing the fill="currentColor" attribute, so they didn’t respond to parent color changes. To fix this, we updated our processing script to patch the SVGs on the fly using the SVGO library:

const processSvgContent = (svgContent: string): string => {
  const { data } = optimize(svgContent, {
    multipass: true,
    plugins: [
      {
        name: "addAttributesToSVGElement",
        params: {
          attributes: [{ fill: "currentColor" }],
        },
      },
    ],
  });

  return data;
};

With this step in place, all icons became fully theme-aware. They now adapt seamlessly to light and dark mode, and can also be styled with Tailwind classes just like any other element.

This wrapped up our journey of bundling over 8,000 emojis and icons in Colanode—fully offline, searchable, and now theme-ready. There’s still room for improvement, such as experimenting with faster ways to load icons in the desktop app or even supporting custom color options in the future, especially when we bring Colanode to mobile.

You can check out the full code for processing emojis here and icons here.

Share this article