<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Notes · REALM examples</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/dist/theme.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js"></script>
<style>
body { margin: 0; min-height: 100svh; display: grid; place-items: center; padding: 1.5rem 1rem; box-sizing: border-box; }
main { width: 100%; display: grid; justify-items: center; }
@media (max-width: 720px) { body { padding: 1rem 0.5rem; } }
</style>
</head>
<body>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/unicons@alpha/dist/unicons.html"></import-element>
<import-element from="./notes-app.html"></import-element>
<main><notes-app></notes-app></main>
</body>
</html>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/button.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/input-group.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/kbd.html"></import-element>
<import-element from="./note-editor.html"></import-element>
<custom-element name="notes-app">
<script type="module">
import { createNote, notes, select, shortcutFor, step, togglePin } from "./notes.js";
async function create() {
createNote();
await tick();
refs.editor.edit();
}
const SHORTCUTS = {
new: create,
pin: togglePin,
next: () => step(1),
previous: () => step(-1),
search: () => refs.search.focus(),
};
function search(field) {
notes.query = field.value;
}
function open(note) {
select(note.id);
}
function shortcut(event) {
const action = SHORTCUTS[shortcutFor(event)];
if (!action) return;
event.preventDefault();
action();
}
listen("keydown", shortcut, document);
</script>
<style>
:host {
display: grid;
grid-template-columns: calc(var(--spacing) * 76) minmax(0, 1fr);
width: min(calc(var(--spacing) * 300), 100%);
height: min(calc(100dvh - var(--spacing) * 12), calc(var(--spacing) * 190));
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-xl);
background: var(--card);
box-shadow: var(--shadow-sm);
}
aside {
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr);
gap: calc(var(--spacing) * 3);
min-height: 0;
padding: calc(var(--spacing) * 4) calc(var(--spacing) * 3);
border-right: 1px solid var(--border);
background: var(--muted);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: calc(var(--spacing) * 2);
padding-inline: calc(var(--spacing) * 1);
}
h1 {
margin: 0;
font-size: var(--text-xl);
letter-spacing: -0.02em;
}
.summary {
margin: 0;
padding-inline: calc(var(--spacing) * 1);
color: var(--muted-foreground);
font-size: var(--text-xs);
}
nav {
min-height: 0;
overflow-y: auto;
}
ul {
display: grid;
gap: calc(var(--spacing) * 1);
margin: 0;
padding: 0;
list-style: none;
}
.note {
display: grid;
gap: calc(var(--spacing) * 0.5);
width: 100%;
padding: calc(var(--spacing) * 2) calc(var(--spacing) * 3);
border: 1px solid transparent;
border-radius: var(--radius-lg);
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.note:hover {
background: var(--accent);
}
.note:focus-visible {
outline: 2px solid var(--ring);
outline-offset: -2px;
}
.note[aria-current="true"] {
border-color: var(--border);
background: var(--background);
box-shadow: var(--shadow-xs);
}
.title {
display: flex;
align-items: center;
gap: calc(var(--spacing) * 1.5);
overflow: hidden;
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
white-space: nowrap;
text-overflow: ellipsis;
}
.title u-icon {
flex-shrink: 0;
color: var(--primary);
}
.note:not([data-pinned]) .title u-icon {
display: none;
}
.excerpt,
.updated {
overflow: hidden;
color: var(--muted-foreground);
font-size: var(--text-xs);
white-space: nowrap;
text-overflow: ellipsis;
}
.empty {
padding: calc(var(--spacing) * 4);
color: var(--muted-foreground);
font-size: var(--text-sm);
text-align: center;
}
@media (max-width: 720px) {
:host {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
height: auto;
min-height: calc(100svh - var(--spacing) * 8);
}
aside {
border-right: 0;
border-bottom: 1px solid var(--border);
}
nav {
max-height: calc(var(--spacing) * 44);
}
.hint {
display: none;
}
}
</style>
<aside>
<header>
<h1>Notes</h1>
<ui-button size="sm" aria-keyshortcuts="Alt+N" onclick="create()"><u-icon name="plus"></u-icon> New <ui-kbd class="hint">Alt N</ui-kbd></ui-button>
</header>
<search>
<ui-input-group ref="search" type="search" aria-label="Search notes" placeholder="Search notes…" aria-keyshortcuts="/" _value="#notes.query" oninput="search(this)">
<ui-input-group-addon><u-icon name="search"></u-icon></ui-input-group-addon>
<ui-input-group-addon align="inline-end" class="hint"><ui-kbd>/</ui-kbd></ui-input-group-addon>
</ui-input-group>
</search>
<p class="summary" role="status"><slot name="#notes.summary"></slot></p>
<nav aria-label="Notes">
<ul>
<li repeat="#notes.visible" as="note" key="id">
<button class="note" type="button" _aria-current="#note.current" _data-pinned="#note.pinned" onclick="open(note)">
<span class="title"><u-icon name="pin" label="Pinned"></u-icon><slot name="#note.title"></slot></span>
<span class="excerpt"><slot name="#note.excerpt"></slot></span>
<span class="updated"><slot name="#note.updated"></slot></span>
</button>
</li>
</ul>
<is-hidden when="#notes.visible.length">
<p class="empty">No notes match your search.</p>
</is-hidden>
</nav>
</aside>
<note-editor ref="editor"></note-editor>
</custom-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/alert-dialog.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/button.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/empty.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/textarea.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/toggle-group.html"></import-element>
<custom-element name="note-editor">
<script type="module">
import { notes, removeCurrent, togglePin, write } from "./notes.js";
function typed(field) {
write(field.value);
}
function show(group) {
notes.view = group.value;
}
function confirmDelete() {
refs.confirm.showModal();
}
function closed(dialog) {
if (dialog.returnValue === "delete") removeCurrent();
}
defineMethods({
edit() {
refs.text.focus();
},
});
</script>
<style>
:host {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-width: 0;
min-height: 0;
}
header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: calc(var(--spacing) * 2);
padding: calc(var(--spacing) * 3) calc(var(--spacing) * 4);
border-bottom: 1px solid var(--border);
}
h2 {
flex: 1;
min-width: 0;
margin: 0;
overflow: hidden;
font-size: var(--text-base);
white-space: nowrap;
text-overflow: ellipsis;
}
.panes {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
min-height: 0;
}
:host(:state(state-notes-view-is-write)) .panes,
:host(:state(state-notes-view-is-preview)) .panes {
grid-template-columns: minmax(0, 1fr);
}
:host(:state(state-notes-view-is-write)) .preview,
:host(:state(state-notes-view-is-preview)) .write {
display: none;
}
.write {
display: grid;
min-height: 0;
padding: calc(var(--spacing) * 4);
}
.write ui-textarea::part(textarea) {
height: 100%;
resize: none;
font-family: var(--font-mono);
font-size: var(--text-sm);
line-height: var(--leading-normal);
}
.preview {
min-height: 0;
overflow-y: auto;
padding: calc(var(--spacing) * 2) calc(var(--spacing) * 6);
border-left: 1px solid var(--border);
line-height: var(--leading-normal);
}
:host(:state(state-notes-view-is-preview)) .preview {
border-left: 0;
}
.preview h3,
.preview h4,
.preview h5,
.preview h6 {
margin: calc(var(--spacing) * 5) 0 calc(var(--spacing) * 2);
line-height: var(--leading-tight);
}
.preview h3 {
font-size: var(--text-2xl);
letter-spacing: -0.02em;
}
.preview h4 {
font-size: var(--text-lg);
}
.preview code {
padding: 0 calc(var(--spacing) * 1);
border-radius: var(--radius-sm);
background: var(--muted);
font-family: var(--font-mono);
font-size: 0.9em;
}
.preview pre {
overflow-x: auto;
padding: calc(var(--spacing) * 3);
border-radius: var(--radius-md);
background: var(--muted);
}
.preview pre code {
padding: 0;
}
.preview blockquote {
margin: calc(var(--spacing) * 4) 0;
padding-left: calc(var(--spacing) * 4);
border-left: 3px solid var(--border);
color: var(--muted-foreground);
}
.preview a {
color: var(--primary);
}
.preview hr {
border: 0;
border-top: 1px solid var(--border);
}
ui-empty {
align-self: center;
}
@media (max-width: 720px) {
.panes {
grid-template-columns: minmax(0, 1fr);
}
.write ui-textarea::part(textarea) {
min-height: calc(var(--spacing) * 60);
}
.preview {
border-left: 0;
border-top: 1px solid var(--border);
padding-inline: calc(var(--spacing) * 4);
}
:host(:state(state-notes-view-is-preview)) .preview {
border-top: 0;
}
}
</style>
<is-visible when="#notes.current">
<header>
<h2><slot name="#notes.title"></slot></h2>
<ui-toggle-group aria-label="View" _value="#notes.view" onchange="show(this)">
<ui-toggle value="write" size="sm">Write</ui-toggle>
<ui-toggle value="split" size="sm">Split</ui-toggle>
<ui-toggle value="preview" size="sm">Preview</ui-toggle>
</ui-toggle-group>
<ui-toggle size="sm" aria-label="Pin note" aria-keyshortcuts="Alt+P" _pressed="#notes.current.pinned" onchange="togglePin()"><u-icon name="pin"></u-icon></ui-toggle>
<ui-button variant="ghost" size="icon-sm" aria-label="Delete note" onclick="confirmDelete()"><u-icon name="trash-2"></u-icon></ui-button>
</header>
<div class="panes">
<div class="write">
<ui-textarea ref="text" aria-label="Note text" _value="#notes.current.body" oninput="typed(this)"></ui-textarea>
</div>
<article class="preview" aria-label="Preview"><slot name="#notes.preview" html></slot></article>
</div>
</is-visible>
<is-hidden when="#notes.current">
<ui-empty>
<ui-empty-header>
<ui-empty-media variant="icon"><u-icon name="notebook-pen"></u-icon></ui-empty-media>
<ui-empty-title level="2">No note selected</ui-empty-title>
<ui-empty-description>Pick a note from the list, or press Alt+N to start a new one.</ui-empty-description>
</ui-empty-header>
</ui-empty>
</is-hidden>
<ui-alert-dialog ref="confirm" onclose="closed(this)">
<ui-alert-dialog-header>
<ui-alert-dialog-title>Delete this note?</ui-alert-dialog-title>
<ui-alert-dialog-description>“<slot name="#notes.title"></slot>” will be removed from this browser.</ui-alert-dialog-description>
</ui-alert-dialog-header>
<ui-alert-dialog-footer>
<ui-alert-dialog-cancel>Cancel</ui-alert-dialog-cancel>
<ui-alert-dialog-action value="delete" variant="destructive">Delete</ui-alert-dialog-action>
</ui-alert-dialog-footer>
</ui-alert-dialog>
</custom-element>
import { defineStore } from "https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js";
import { markdown } from "./markdown.js";
const stamp = new Intl.DateTimeFormat("en", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
const WELCOME = `# Welcome to Notes
Notes are **Markdown**. The preview updates as you type, and everything is saved in this browser.
## Shortcuts
- \`Alt+N\` creates a note
- \`Alt+P\` pins or unpins the current note
- \`Alt+↑\` and \`Alt+↓\` move between notes
- \`/\` jumps to search
> Pinned notes stay at the top of the list.
Read more about [REALM](https://realm.js.org/).`;
const seed = () => [
{ id: "welcome", body: WELCOME, pinned: true, updated: Date.UTC(2026, 9, 18, 9, 30) },
{
id: "groceries",
body: "# Groceries\n\n- Oat milk\n- Coffee beans\n- ~~Bread~~\n- Tomatoes",
pinned: false,
updated: Date.UTC(2026, 9, 17, 17, 5),
},
{
id: "standup",
body: "# Standup\n\n1. Ship the *notes* example\n2. Review `defineStore` docs\n\n```\npnpm --filter site test\n```",
pinned: false,
updated: Date.UTC(2026, 9, 16, 8, 45),
},
];
const titleOf = (body) => {
const first = body.split("\n").find((line) => line.trim()) ?? "";
return first.replace(/^#+\s*/, "").trim() || "Untitled";
};
const excerptOf = (body) =>
body
.split("\n")
.slice(1)
.join(" ")
.replace(/[#>*_`~-]/g, "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 80) || "No additional text";
export const notes = defineStore(
"example-notes",
{
items: seed(),
selectedId: "welcome",
query: "",
view: "split",
get current() {
return this.items.find((note) => note.id === this.selectedId) ?? null;
},
get ordered() {
return this.items.toSorted((a, b) => b.pinned - a.pinned || b.updated - a.updated);
},
get visible() {
const query = this.query.trim().toLowerCase();
return this.ordered
.filter((note) => note.body.toLowerCase().includes(query))
.map((note) => ({
id: note.id,
pinned: note.pinned,
title: titleOf(note.body),
excerpt: excerptOf(note.body),
updated: stamp.format(note.updated),
current: note.id === this.selectedId,
}));
},
get title() {
return this.current ? titleOf(this.current.body) : "";
},
get preview() {
return this.current ? markdown(this.current.body) : "";
},
get summary() {
const count = this.visible.length;
return `${count} ${count === 1 ? "note" : "notes"}`;
},
},
{ persist: "local" },
);
export const createNote = () => {
const note = { id: crypto.randomUUID(), body: "# ", pinned: false, updated: Date.now() };
notes.items.push(note);
notes.query = "";
notes.selectedId = note.id;
};
export const select = (id) => {
notes.selectedId = id;
};
export const write = (body) => {
notes.current.body = body;
notes.current.updated = Date.now();
};
export const togglePin = () => {
if (notes.current) notes.current.pinned = !notes.current.pinned;
};
export const removeCurrent = () => {
const list = notes.visible;
const index = list.findIndex((note) => note.current);
const next = list[index + 1] ?? list[index - 1];
notes.items = notes.items.filter((note) => note.id !== notes.selectedId);
notes.selectedId = next?.id ?? null;
};
export const step = (offset) => {
const list = notes.visible;
const index = list.findIndex((note) => note.current);
const next = list[Math.min(Math.max(index + offset, 0), list.length - 1)];
if (next) notes.selectedId = next.id;
};
const editing = (event) =>
event
.composedPath()
.some((node) => node.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(node.nodeName));
const ALT_KEYS = { KeyN: "new", KeyP: "pin", ArrowDown: "next", ArrowUp: "previous" };
export const shortcutFor = (event) => {
if (event.ctrlKey || event.metaKey) return undefined;
if (event.altKey) return ALT_KEYS[event.code];
return event.key === "/" && !editing(event) ? "search" : undefined;
};
// A small Markdown subset: headings, paragraphs, lists, quotes, rules, fenced code and inline
// code, bold, italic, strikethrough and links. Text is escaped first, and REALM's `html` slot
// sanitises the result again before it reaches the page.
const ENTITIES = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
const SAFE_URL = /^(?:https?:|mailto:|#|\/|\.{1,2}\/)/i;
const escapeHtml = (text) => text.replace(/[&<>"']/g, (char) => ENTITIES[char]);
const INLINE = [
[/\*\*(.+?)\*\*/g, "<strong>$1</strong>"],
[/(^|[^*])\*([^*\s][^*]*?)\*/g, "$1<em>$2</em>"],
[/(^|\W)_([^_\s][^_]*?)_(?=\W|$)/g, "$1<em>$2</em>"],
[/~~(.+?)~~/g, "<del>$1</del>"],
[
/\[([^\]]+)\]\(([^)\s]+)\)/g,
(_, label, url) => (SAFE_URL.test(url) ? `<a href="${url}">${label}</a>` : label),
],
];
const inline = (text) =>
text
.split(/(`[^`]+`)/)
.map((part, index) =>
index % 2
? `<code>${escapeHtml(part.slice(1, -1))}</code>`
: INLINE.reduce(
(html, [pattern, replacement]) => html.replace(pattern, replacement),
escapeHtml(part),
),
)
.join("");
const FENCE = /^```/;
const HEADING = /^(#{1,6})\s+(.*)$/;
const RULE = /^(?:-{3,}|\*{3,}|_{3,})\s*$/;
const QUOTE = /^>\s?/;
const BULLET = /^\s*[-*+]\s+/;
const NUMBER = /^\s*\d+[.)]\s+/;
const BLANK = /^\s*$/;
const startsBlock = (line) =>
[FENCE, HEADING, RULE, QUOTE, BULLET, NUMBER, BLANK].some((pattern) => pattern.test(line));
const takeWhile = (lines, start, test) => {
let end = start;
while (end < lines.length && test(lines[end])) end++;
return end;
};
// Headings start at h3 because the page has the h1 and the note list and editor use h2.
const BLOCKS = [
{
test: (line) => FENCE.test(line),
read(lines, start) {
const end = takeWhile(lines, start + 1, (line) => !FENCE.test(line));
const code = escapeHtml(lines.slice(start + 1, end).join("\n"));
return { html: `<pre><code>${code}</code></pre>`, next: end + 1 };
},
},
{
test: (line) => HEADING.test(line),
read(lines, start) {
const [, marks, text] = lines[start].match(HEADING);
const level = Math.min(marks.length + 2, 6);
return { html: `<h${level}>${inline(text)}</h${level}>`, next: start + 1 };
},
},
{
test: (line) => RULE.test(line),
read: (_, start) => ({ html: "<hr>", next: start + 1 }),
},
{
test: (line) => QUOTE.test(line),
read(lines, start) {
const end = takeWhile(lines, start, (line) => QUOTE.test(line));
const inner = lines.slice(start, end).map((line) => line.replace(QUOTE, ""));
return { html: `<blockquote>${markdown(inner.join("\n"))}</blockquote>`, next: end };
},
},
...[
[BULLET, "ul"],
[NUMBER, "ol"],
].map(([marker, tag]) => ({
test: (line) => marker.test(line),
read(lines, start) {
const end = takeWhile(lines, start, (line) => marker.test(line));
const items = lines
.slice(start, end)
.map((line) => `<li>${inline(line.replace(marker, ""))}</li>`);
return { html: `<${tag}>${items.join("")}</${tag}>`, next: end };
},
})),
{
test: (line) => BLANK.test(line),
read: (_, start) => ({ html: "", next: start + 1 }),
},
];
const paragraph = (lines, start) => {
const end = takeWhile(lines, start + 1, (line) => !startsBlock(line));
return { html: `<p>${lines.slice(start, end).map(inline).join("<br>")}</p>`, next: end };
};
export const markdown = (source) => {
const lines = source.replace(/\r\n?/g, "\n").split("\n");
const html = [];
let index = 0;
while (index < lines.length) {
const line = lines[index];
const block = BLOCKS.find((candidate) => candidate.test(line));
const { html: part, next } = block ? block.read(lines, index) : paragraph(lines, index);
html.push(part);
index = next;
}
return html.join("");
};