We use cookies to enhance your experience. By clicking "Accept," you consent to the use of all cookies. Learn more in our policy.
Launch a custom Notion widget from scratch. Learn HTML/CSS basics, hosting on Vercel/Netlify, embed rules, fonts, and performance tips — plus when to use Blocky’s native charts, timers, and study widgets.
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.
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:
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:
That mix lets me move fast: hand-code the edge cases, use Blocky for the rest.
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:
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.
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:
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.
I keep my HTML small and semantic:
<main>
or <div role="region">
as the widget shell.<h2>
/<h3>
) for screen readers and structure.<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:
<head>
with title and viewport.I test my widget standalone first in a normal browser tab. If it looks right there, it usually looks right embedded.
CSS can be deep, but a few patterns carry most of the weight:
rem
) and a tight line-height.Selectors to master:
h3
, p
), class (.card
), ID (#cta
).[data-state="active"]
) for simple component states.Keep your stylesheet organized by component or section. Add clear comments, and resist clever hacks. Simple CSS fails less when embedded.
I start with a small card:
Layout:
.card
container with padding and rounded corners.display: grid
with a narrow gap to stack text cleanly.Typography:
font-size
on the root (e.g., 16px
), then use rem
everywhere.letter-spacing
for UIs that need extra clarity.Color:
Result: a widget that feels native to your workspace.
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:
<link>
or CSS @import
from the Getting Started docs.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.
To embed in Notion, my widget needs a public HTTPS URL. Any static host works. I reach for:
Flow I use almost every time:
/index.html
and /styles.css
.Tip: disable directory listings and keep the surface area small. One page, one stylesheet, and you’re done.
Inside the Notion page:
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:
Your widget will live inside Notion’s iframe, but your server decides whether embedding is allowed. Two mechanisms matter:
X-Frame-Options
response headerDENY
: never embed.SAMEORIGIN
: only embed on your own domain.ALLOW-FROM
is obsolete; don’t use it.frame-ancestors
)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.
Fast wins trust. I keep the page tiny:
Fonts:
font-display: swap
for better perceived speed.Images:
Responsive:
max-width
.clamp()
or rem
.The goal: instant render inside the Notion block.
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.
Even with plain HTML/CSS, you can do a lot:
data-state="active"
for styling states.prefers-color-scheme
media query for automatic theming.Want interactivity?
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.
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.
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>
Create your own or customize one of Blocky’s 60+ widgets to make your Notion dashboard truly yours.