diff --git a/README.md b/README.md
index 23c025f..5a52b73 100644
--- a/README.md
+++ b/README.md
@@ -50,7 +50,7 @@ $$ n \in [r_{min}, r]; n = r_{min} + t \mod (r - r_{min}) $$
 - [ ] Log generated ASNs to the file system
 - [x] Connection to a database
 - [x] REST API
-- [ ] Visual Web Interface
+- [x] Visual Web Interface
 - [ ] CLI
   - [ ] Bump counter to avoid collissions after restoring backups (where ASNs
         could have been generated after the time of the backup)
@@ -61,3 +61,4 @@ $$ n \in [r_{min}, r]; n = r_{min} + t \mod (r - r_{min}) $$
 - [ ] Publish on JSR
 - [ ] Publish on Docker Hub
 - [x] Publish on GitHub Container Registry
+- [ ] UI for quickly searching for ASNs in a DMS
diff --git a/deno.json b/deno.json
index 097a5d5..8397c2c 100644
--- a/deno.json
+++ b/deno.json
@@ -13,7 +13,8 @@
     "$cli/": "./lib/cli/",
     "@hono/hono": "jsr:@hono/hono@^4.5.11",
     "@std/datetime": "jsr:@std/datetime@^0.225.2",
-    "@collinhacks/zod": "npm:zod@^3.23.8"
+    "@collinhacks/zod": "npm:zod@^3.23.8",
+    "@metafloor/bwip-js": "npm:bwip-js@^4.5.1"
   },
   "compilerOptions": {
     "jsx": "precompile",
diff --git a/deno.lock b/deno.lock
index 5d561f2..b4517dc 100644
--- a/deno.lock
+++ b/deno.lock
@@ -4,6 +4,7 @@
     "specifiers": {
       "jsr:@hono/hono@^4.5.11": "jsr:@hono/hono@4.5.11",
       "npm:@types/node": "npm:@types/node@18.16.19",
+      "npm:bwip-js@^4.5.1": "npm:bwip-js@4.5.1",
       "npm:zod@^3.23.8": "npm:zod@3.23.8"
     },
     "jsr": {
@@ -16,6 +17,10 @@
         "integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==",
         "dependencies": {}
       },
+      "bwip-js@4.5.1": {
+        "integrity": "sha512-83yQCKiIftz5YonnsTh6wIkFoHHWl+B/XaGWD1UdRw7aB6XP9JtyYP9n8sRy3m5rzL+Ch/RUPnu28UW0RrPZUA==",
+        "dependencies": {}
+      },
       "zod@3.23.8": {
         "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==",
         "dependencies": {}
@@ -27,6 +32,7 @@
     "dependencies": [
       "jsr:@hono/hono@^4.5.11",
       "jsr:@std/datetime@^0.225.2",
+      "npm:bwip-js@^4.5.1",
       "npm:zod@^3.23.8"
     ]
   }
diff --git a/lib/common/asn.ts b/lib/common/asn.ts
index 878c225..cd5fef7 100644
--- a/lib/common/asn.ts
+++ b/lib/common/asn.ts
@@ -2,6 +2,14 @@ import { CONFIG } from "$common/config.ts";
 import { performAtomicTransaction } from "$common/db.ts";
 import { ensureFileContent, getCounterPath } from "$common/path.ts";
 
+export interface ASNData {
+  asn: string;
+  namespace: number;
+  prefix: string;
+  counter: number;
+  metadata: Record<string, unknown>;
+}
+
 function getCurrentNamespace(): number {
   const date = Date.now();
   const range = getRange();
@@ -19,7 +27,7 @@ function getRange() {
  * The ASN is composed of a prefix, a namespace, and a counter.
  * @returns a new ASN (Alphanumeric Serial Number)
  */
-export async function generateASN(metadata: Record<string, unknown> = {}) {
+export async function generateASN(metadata: Record<string, unknown> = {}): Promise<ASNData> {
   metadata = { ...metadata, generatedAt: new Date().toISOString() };
   const namespace = getCurrentNamespace();
   let counter = 0;
@@ -81,3 +89,9 @@ export function getFormatDescription(): string {
     `    The user defined namespace can be used for pre-printed ASN barcodes and the like.\n` +
     `(3) Counter, starting from 001, incrementing with each new ASN in the namespace.`;
 }
+
+export function validateASN(asn: string): boolean {
+  return new RegExp(
+    `^${CONFIG.ASN_PREFIX}(\\d{${`${CONFIG.ASN_NAMESPACE_RANGE}`.length}})(\\d{3})\\d*$`,
+  ).test(asn);
+}
diff --git a/lib/common/config.ts b/lib/common/config.ts
index acf993f..bc6afdb 100644
--- a/lib/common/config.ts
+++ b/lib/common/config.ts
@@ -11,7 +11,7 @@ interface Config {
 
 const configSchema = z.object({
   PORT: z.number({ coerce: true }).default(8080),
-  ASN_PREFIX: z.string().min(1),
+  ASN_PREFIX: z.string().min(1).max(10).regex(/^[A-Z]+$/),
   ASN_NAMESPACE_RANGE: z.number({ coerce: true }),
 });
 
diff --git a/lib/http/barcode-svg.ts b/lib/http/barcode-svg.ts
new file mode 100644
index 0000000..05e2cd0
--- /dev/null
+++ b/lib/http/barcode-svg.ts
@@ -0,0 +1,12 @@
+import * as bwip from "@metafloor/bwip-js";
+
+export function createBarcodeSVG(data: string, embedded = false): string {
+	return bwip.toSVG({
+		bcid: 'code128',       // Barcode type
+		text: data,    // Text to encode
+		scale: 3,              // 3x scaling factor
+		height: 10,             // Bar height, in millimeters
+		includetext: !embedded, // Show human-readable text
+		textxalign: 'center',   // Always good to set this
+	});
+}
\ No newline at end of file
diff --git a/lib/http/mod.tsx b/lib/http/mod.tsx
index 1104cfc..52f44db 100644
--- a/lib/http/mod.tsx
+++ b/lib/http/mod.tsx
@@ -2,11 +2,13 @@ import { Hono } from "@hono/hono";
 import { logger } from "@hono/hono/logger";
 import { jsxRenderer } from "@hono/hono/jsx-renderer";
 import { serveStatic } from "@hono/hono/deno";
-import { generateASN } from "$common/mod.ts";
+
+import { generateASN, getFormatDescription, validateASN } from "$common/mod.ts";
 import metadata from "$/deno.json" with { type: "json" };
 
-import { Wrapper } from "./ui/wrapper.tsx";
+import { Wrapper } from "$http/ui/wrapper.tsx";
 import { IndexPage } from "$http/ui/index.tsx";
+import { createBarcodeSVG } from "$http/barcode-svg.ts";
 
 export const httpApp = new Hono();
 
@@ -15,8 +17,36 @@ httpApp.get(
 	"/about",
 	(c) => c.text(`${metadata.name} v${metadata.version} is running!`),
 );
+httpApp.get("/format", (c) => c.text(getFormatDescription()));
 httpApp.get("/json", async (c) => c.json(await generateASN()));
 httpApp.get("/static/*", serveStatic({ root: "." }));
 
+httpApp.get("/svg/:asn", (c) => {
+	const requestedASN = c.req.param("asn");
+
+	if (!requestedASN) {
+		return c.text("No ASN provided", 400);
+	}
+
+	if (!validateASN(requestedASN)) {
+		return c.text("Invalid ASN provided", 400);
+	}
+
+	const barcode = createBarcodeSVG(requestedASN, !!c.req.query("embed"));
+
+	return c.body(barcode ?? "", 200, {
+		"Cache-Control": "no-cache",
+		"Content-Type": "image/svg+xml",
+	});
+});
+
+httpApp.get("/svg", (c) => {
+	const barcode = createBarcodeSVG("0123456789", !!c.req.query("embed"));
+	return c.body(barcode ?? "", 200, {
+		"Cache-Control": "no-cache",
+		"Content-Type": "image/svg+xml",
+	});
+});
+
 httpApp.use("*", jsxRenderer(Wrapper));
 httpApp.use("/", async (c) => await c.render(<IndexPage />));
diff --git a/lib/http/ui/index.tsx b/lib/http/ui/index.tsx
index b7caf15..3f15ead 100644
--- a/lib/http/ui/index.tsx
+++ b/lib/http/ui/index.tsx
@@ -1,3 +1,37 @@
-export function IndexPage() {
-	return <h1>Test</h1>
+import { css, cx } from "@hono/hono/css";
+import { generateASN } from "$common/asn.ts";
+
+const hideOnPrint = css`
+@media print {
+	display: none;
+}
+`
+
+const mainClass = css`
+display: flex;
+flex-direction: column;
+gap: 0rem;
+justify-content: center;
+align-items: center;
+`
+
+export async function IndexPage() {
+	const asn = await generateASN();
+	const script = {__html: `globalThis.asn = ${JSON.stringify(asn)};`};
+	return <>
+		<script dangerouslySetInnerHTML={script}></script>
+		<header class={cx(hideOnPrint)}>
+			<button onclick={'globalThis.copy()'}>Copy</button>
+			<button onclick={'globalThis.location.reload()'}>Reload</button>
+			<button onclick={'globalThis.print()'}>Print</button>
+			<a href={`/svg/${asn.asn}`} download>Download</a>
+		</header>
+		<main class={mainClass}>
+			<img src={`/svg/${asn.asn}?embed=true`} alt="Barcode" />
+			<p>{asn.asn}</p>
+		</main>
+		<footer class={cx(hideOnPrint)}>
+			<a href="/about">About</a>
+		</footer>
+	</>
 }
\ No newline at end of file
diff --git a/lib/http/ui/wrapper.tsx b/lib/http/ui/wrapper.tsx
index f311ad3..e7bb6c9 100644
--- a/lib/http/ui/wrapper.tsx
+++ b/lib/http/ui/wrapper.tsx
@@ -6,12 +6,30 @@ background: var(--primary-color);
 display: grid;
 place-items: center;
 align-content: center;
-`
+margin: 0;
+padding: 1rem;
+
+@media print {
+	background: transparent;
+	height: 100%;
+	align-content: stretch;
+	place-items: stretch;
+}
+`;
 
 const mainClass = css`
 background: white;
 font-family: var(--font-family);
-`
+width: clamp(0px, 100%, 20rem);
+overflow-x: hidden;
+padding: 1rem;
+
+@media print {
+	width: 100%;
+	height: 100%;
+	border: 1px solid var(--primary-color);
+}
+`;
 
 export function Wrapper({ children }: { children?: Child }) {
 	return (
@@ -19,16 +37,23 @@ export function Wrapper({ children }: { children?: Child }) {
 			<head>
 				<title>Wrapper</title>
 				<link rel="stylesheet" href="/static/theme.css" />
-				<Style>{css`
+				<Style>
+					{css`
 				* {
 					box-sizing: border-box;
 				}
-				`}</Style>
+
+				html, body {
+					height: 100%;
+				}
+				`}
+				</Style>
 			</head>
 			<body class={cx(bodyClass)}>
 				<main class={mainClass}>
-				{children}
+					{children}
 				</main>
+				<script src="/static/main.js"></script>
 			</body>
 		</html>
 	);
diff --git a/static/main.js b/static/main.js
new file mode 100644
index 0000000..95548aa
--- /dev/null
+++ b/static/main.js
@@ -0,0 +1,36 @@
+/**
+ * The ASN rendered by the current page
+ * @type {import("$common/asn.ts").ASNData | undefined}
+ */
+const asn = globalThis.asn;
+
+init()
+  .then(() => console.log("✅ Interactive Elements initialized"))
+  .catch((err) =>
+    console.error(
+      "🛑 An error occured while initializing Interactive Elements",
+      err
+    )
+  );
+
+async function init() {
+  console.log("⌚️ Initializing Interactive Elements...");
+  if (!asn) {
+    console.warn("ASN not found");
+    return;
+  }
+
+  console.debug("ASN found:", asn);
+
+  globalThis.copy = () => {
+    navigator.clipboard.writeText(asn.asn).then(() => {
+      console.log("✅ Copied ASN to clipboard");
+    });
+  };
+
+  globalThis.downloadBarcode = () => {
+    // TODO: Implement barcode download
+  }
+
+  await Promise.resolve();
+}
diff --git a/static/theme.css b/static/theme.css
index 7babb97..1ac1dde 100644
--- a/static/theme.css
+++ b/static/theme.css
@@ -1,4 +1,12 @@
 :root {
 	--primary-color: #452897;
 	--font-family: 'Roboto', Arial, sans-serif;
-}
\ No newline at end of file
+}
+
+body {
+	font-family: var(--font-family);
+}
+
+a {
+	color: var(--primary-color);
+}