I love simple tools that ship fast. And Notion rewards that mindset.
In this guide, I show how I create a crisp, custom widget in plain HTML and CSS,
host it, and embed it inside Notion — without fighting a giant framework.
You’ll see exactly how embeds work in Notion. You’ll learn the bare-minimum
HTML/CSS I actually use in production. I’ll cover hosting, security headers,
and common pitfalls. I’ll also explain when Blocky (https://blocky.so) is the
smarter path for charts, timers, and study widgets.
By the end, you’ll have a shareable URL for your widget and the confidence
to drop it into any Notion page. Let’s make something tidy, fast, and useful.
Why build your own Notion widget?
A custom widget fills the gaps between “general-purpose” and “exactly what I need.”
Maybe you want a brand-colored KPI badge. Or a quote card that rotates daily.
Or an availability banner that your team sees at a glance.
I reach for hand-built widgets when:
- I want a very specific layout or animation.
- I need my brand’s type scale and color tokens.
- I’ll iterate fast and don’t want vendor lock-in.
And yet, I don’t reinvent the wheel. For complex use cases
like charts, countdowns, world clocks, Pomodoro, stopwatches,
habit trackers, flashcards, progress bars, mood trackers, quotes, or
Notion streaks, I use Blocky (https://blocky.so). It’s an official Notion
integration with generous pricing:
- Free: up to 2 charts and 5 total widgets (charts count as widgets).
- Standard: 5 charts, 10 widgets, $3.99/month.
- Pro: Unlimited everything, $5.99/month.
That mix lets me move fast: hand-code the edge cases, use Blocky for the rest.
How Notion embeds work (and what Notion allows)
Notion supports an Embed block. The flow is simple:
type /embed, paste a public URL, and Notion renders that page within your doc.
That means I don’t paste raw HTML into Notion — I host my widget, then embed the link.
Two practical implications:
- My widget must be publicly reachable via HTTPS.
- The page must allow itself to be shown inside an iframe.
If my server sends headers that forbid iframes, Notion won’t show it.
I’ll cover the relevant headers later — X-Frame-Options and CSP frame-ancestors.
For now, remember: host a simple public page and ensure it’s embeddable.
Tip: if I’m embedding YouTube, I use the /embed/ URL,
not the watch?v= URL. Many services follow a similar pattern.
Build vs. buy: when Blocky is smarter
I’m ruthless about time. If I need data-bound charts, timers, or study tools,
I use Blocky (https://blocky.so). It connects to my Notion workspace in a few clicks,
offers polished variants, and handles sync, state, and edge cases I’d rather not own.
What I reach for inside Blocky:
- Charts: Bar, Line, Pie, Area, Radar — mapped to Notion databases.
- Timers: Countdowns, world clocks, Pomodoro, stopwatches.
- Study widgets: Habit trackers, flashcards, progress bars, streaks.
- Creative: Mood trackers, quotes, and more.
Because Blocky is an official Notion integration (https://www.notion.com/integrations/blocky),
it embeds cleanly, loads fast, and reduces maintenance. I hand-code when
I want pixel-perfect control or a one-off UX. I use Blocky for everything else.
HTML essentials you’ll actually use
I keep my HTML small and semantic:
- A parent
<main>or<div role="region">as the widget shell. - A heading (
<h2>/<h3>) for screen readers and structure. - Content blocks like
<p>,<time>,<ul>,<button>when needed.
For embeds, the outside world (Notion) wraps my page in an <iframe>,
so I don’t add one myself. I just produce a clean, self-contained page.
My baseline document:
- A minimal
<head>with title and viewport. - A single stylesheet in the head for speed.
- Zero blocking scripts unless absolutely necessary.
I test my widget standalone first in a normal browser tab.
If it looks right there, it usually looks right embedded.
CSS fundamentals for reliable styling
CSS can be deep, but a few patterns carry most of the weight:
- Reset/normalize with a light touch (I prefer per-component resets).
- Scale typography with relative units (
rem) and a tight line-height. - Use CSS variables for color tokens so theming is trivial.
- Flexbox for micro-layout, Grid for macro layout.
Selectors to master:
- Type (
h3,p), class (.card), ID (#cta). - Attribute (
[data-state="active"]) for simple component states. - Child and descendant combinators for structure.
Keep your stylesheet organized by component or section.
Add clear comments, and resist clever hacks.
Simple CSS fails less when embedded.
Designing your first widget (layout, type, color)
I start with a small card:
- A title line.
- A value or message.
- An optional subtitle or status pill.
Layout:
- Use a single
.cardcontainer with padding and rounded corners. - Apply
display: gridwith a narrow gap to stack text cleanly. - Give the shell a soft shadow and a strict max-width.
Typography:
- Choose two weights of a single font family.
- Set
font-sizeon the root (e.g.,16px), then useremeverywhere. - Adjust
letter-spacingfor UIs that need extra clarity.
Color:
- Pick a neutral background and high-contrast text.
- Reserve accent color for the metric or callout.
- Add a light interactive hover state if it’s clickable.
Result: a widget that feels native to your workspace.
Fonts the right way (fast and legal)
Good typography elevates even simple widgets.
I either use system fonts for zero-latency performance
or load Google Fonts via the official API.
Steps I follow:
- Choose a family and weights on Google Fonts (https://fonts.google.com).
- Copy the
<link>or CSS@importfrom the Getting Started docs. - Declare a robust fallback stack.
I avoid loading six weights. Two is plenty for UI.
If I need fine control over FOIT/FOUT,
I’ll use the WebFont Loader or native font-display rules.
Remember: load fonts from trusted CDNs and keep them cached.
Hosting options and going live (pick one, ship today)
To embed in Notion, my widget needs a public HTTPS URL.
Any static host works. I reach for:
- Vercel (https://vercel.com) — frictionless for static sites.
- Netlify (https://www.netlify.com) — solid, simple deploys.
- Cloudflare Pages (https://pages.cloudflare.com) — fast CDN, easy setup.
- GitHub Pages (https://pages.github.com) — free and predictable.
Flow I use almost every time:
- Create a repo with
/index.htmland/styles.css. - Push to GitHub. Connect repository to my host.
- Deploy, grab the public URL, and keep it handy for Notion.
Tip: disable directory listings and keep the surface area small.
One page, one stylesheet, and you’re done.
Embedding your widget in Notion (the exact steps)
Inside the Notion page:
- Type /embed.
- Paste your widget’s public URL.
- Resize the block until it fits your layout.
- Add a caption if you want extra context.
That’s it. If you’re using Blocky widgets,
the flow is identical: generate a share URL in Blocky,
paste into Notion, and position it beside your content.
If the embed fails:
- Confirm the URL is public.
- Check that your host allows iframes.
- Try a different browser to rule out extensions.
Securing iframes, CSP, and anti-clickjacking basics
Your widget will live inside Notion’s iframe, but your server
decides whether embedding is allowed. Two mechanisms matter:
X-Frame-Optionsresponse header
DENY: never embed.SAMEORIGIN: only embed on your own domain.ALLOW-FROMis obsolete; don’t use it.
- Content-Security-Policy (
frame-ancestors)
- The modern way.
- Controls which parents may embed your page.
If you block embedding, Notion can’t render your widget.
If you allow it too broadly, you risk clickjacking on other sites.
I keep it conservative. For public widgets intended for Notion,
I set a narrowly-scoped frame-ancestors or rely on a host’s defaults.
Key point: These headers are sent by your server.
They can’t be set inside your HTML and still be effective.
Performance and polish (fonts, images, responsive)
Fast wins trust. I keep the page tiny:
- Inline critical CSS if it’s under ~5–8 KB.
- Defer any non-essential scripts.
- Minify HTML/CSS to shave bytes.
Fonts:
- Use two weights max.
- Prefer
font-display: swapfor better perceived speed. - Cache aggressively.
Images:
- SVG for vector icons.
- Use modern formats for small photos if any.
- Avoid heavy backgrounds — Notion already frames your content.
Responsive:
- Cap width with
max-width. - Scale padding and font sizes with
clamp()orrem. - Test at Notion’s narrow column width and full-width pages.
The goal: instant render inside the Notion block.
Troubleshooting common embed issues
Blank iframe?
Your host might be sending X-Frame-Options: DENY or a restrictive frame-ancestors.
Check your response headers with your browser’s devtools or curl -I.
“Refused to connect” error?
Some services disallow being embedded off-site.
There’s nothing you can fix client-side if the server forbids it.
Host your own page or use the platform’s official embed URL.
Layout looks cramped in Notion?
Give your widget a neutral background and generous inner padding.
Notion’s outer padding is tight; compensate inside your card.
Font looks different in Notion?
Ensure the font loads over HTTPS and isn’t blocked by CSP.
Always define a robust fallback stack.
Advanced ideas: states, theming, interactivity
Even with plain HTML/CSS, you can do a lot:
- Data attributes like
data-state="active"for styling states. - CSS variables for light/dark themes you toggle with one class.
prefers-color-schememedia query for automatic theming.
Want interactivity?
- Add a tiny script that rotates quotes or fetches a status string.
- Use progressive enhancement: load the script after initial paint.
- Keep a no-JS fallback so your widget degrades gracefully.
If you need complex state, authentication, or database sync,
jump to Blocky (https://blocky.so). It already solves the hard bits,
from timers to charts to study widgets that talk to Notion.
Wrap-up: you’ve got options (and momentum)
Build Your Own Notion Widget: A Beginner’s Guide to Custom HTML & CSS
isn’t about theory; it’s a repeatable flow:
design a small card, host it, and embed it.
When I need custom styling or a quirky layout,
I hand-craft the widget. When I need reliable features
like charts, timers, or study tools that sync with Notion,
I use Blocky (https://blocky.so) and move on.
Both approaches coexist beautifully. Use the right one for the job.
Your Notion pages will look sharper, feel faster,
and communicate more at a glance. That’s the win.
Free Basic Clock Example
Here’s a basic example — a simple live digital clock you can host and embed:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Custom Notion Clock</title>
<style>
body {
margin: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: #111827;
color: #22d3ee;
font-family: sans-serif;
font-size: 3rem;
}
</style>
</head>
<body>
<div id="clock"></div>
<script>
function updateClock() {
const now = new Date();
const time = now.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
document.getElementById('clock').textContent = time;
}
setInterval(updateClock, 1000);
updateClock();
</script>
</body>
</html>
Other Articles
- How To Gamify Notion
- Notion For Product Management
- How To Make A Pie Chart
- Create A Flashcard System
- How To Add Live Data Widgets
