60 FPS · 8 MB RAM · CSS float · Ruby typography · XSS-safe by default
CSS float layout · crash-free text selection · CJK/Furigana · @keyframes · Flexbox/Grid
Drop-in replacement for flutter_html and flutter_widget_from_html.
Quick Start · Why Switch? · API · Packages
dependencies:
hyper_render: ^1.1.3import 'package:hyper_render/hyper_render.dart';
HyperViewer(
html: articleHtml,
onLinkTap: (url) => launchUrl(Uri.parse(url)),
)Zero configuration. XSS sanitization is on by default. Works for articles, emails, docs, newsletters, and CJK content.
Most Flutter HTML libraries map each HTML tag to a Flutter widget. A 3 000-word article becomes 500+ nested widgets — and some layout primitives simply cannot be expressed that way:
CSS
floatis not possible in a widget tree. Wrapping text around a floated image requires every fragment's coordinates before adjacent text can be composed. That geometry only exists when a singleRenderObjectowns the entire layout.
HyperRender renders the whole document inside one custom RenderObject. Float, crash-free selection, and sub-millisecond binary-search hit-testing all follow from that single design decision.
| Feature | flutter_html |
flutter_widget_from_html |
HyperRender |
|---|---|---|---|
float: left / right |
❌ | ❌ | ✅ |
| Text selection — large docs | ❌ Crashes | ❌ Crashes | ✅ Crash-free |
| Ruby / Furigana | ❌ Raw text | ❌ Raw text | ✅ |
<details> / <summary> |
❌ | ❌ | ✅ Interactive |
CSS Variables var() |
❌ | ❌ | ✅ |
CSS @keyframes |
❌ | ❌ | ✅ |
| Flexbox / Grid | ✅ Full | ||
Box shadow · filter |
❌ | ❌ | ✅ |
SVG <img src="*.svg"> |
✅ | ||
| Scroll FPS (25 K-char doc) | ~35 | ~45 | 60 |
| RAM (same doc) | 28 MB | 15 MB | 8 MB |
Measured on iPhone 13 + Pixel 6 with a 25 000-character article:
| Metric | flutter_html |
flutter_widget_from_html |
HyperRender |
|---|---|---|---|
| Widgets created | ~600 | ~500 | 3–5 chunks |
| First parse | 420 ms | 250 ms | 95 ms |
| Peak RAM | 28 MB | 15 MB | 8 MB |
| Scroll FPS | ~35 | ~45 | 60 |
HyperViewer(html: '''
<article>
<img src="photo.jpg" style="float:left; width:180px; margin:0 16px 8px 0; border-radius:8px;" />
<h2>The Art of Layout</h2>
<p>Text wraps around the image exactly like a browser — because HyperRender
uses the same block formatting context algorithm.</p>
</article>
''')HyperViewer(
html: longArticleHtml,
selectable: true,
showSelectionMenu: true,
selectionHandleColor: Colors.blue,
)One continuous span tree. Selection crosses headings, paragraphs, and table cells. O(log N) binary-search hit-testing stays instant on 1 000-line documents.
HyperViewer(html: '''
<p style="font-size:20px; line-height:2;">
<ruby>東京<rt>とうきょう</rt></ruby>で
<ruby>日本語<rt>にほんご</rt></ruby>を学ぶ
</p>
''')Furigana centered above base characters. Kinsoku shori applied across the full line.
Ruby copied to clipboard as 東京(とうきょう).
HyperViewer(html: '''
<style>
:root { --brand: #6750A4; --surface: #F3EFF4; }
</style>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
<div style="background:var(--brand); color:white; padding:16px; border-radius:12px;">
Column one — themed with CSS custom properties
</div>
<div style="background:var(--surface); padding:16px; border-radius:12px;">
Column two — same token system
</div>
</div>
''')<style>
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { transform: translateY(24px); opacity: 0; }
to { transform: translateY(0); opacity: 1; } }
.hero { animation: fadeIn 0.6s ease-out; }
.card { animation: slideUp 0.4s ease-out; }
</style>
<div class="hero"><h1>Welcome</h1></div>
<div class="card"><p>Animated without any Dart code.</p></div>Parsed from <style> tags automatically — supports opacity, transform, vendor-prefixed variants,
and percentage selectors.
// Safe — strips <script>, on* handlers, javascript: URLs
HyperViewer(html: userGeneratedContent)
// Custom allowlist for stricter sandboxing
HyperViewer(html: userContent, allowedTags: ['p', 'a', 'img', 'strong', 'em'])
// Disable only for fully trusted, internal HTML
HyperViewer(html: trustedCmsHtml, sanitize: false)HyperViewer(html: '<h1>Hello</h1><p>World</p>')
HyperViewer.delta(delta: '{"ops":[{"insert":"Hello\\n"}]}')
HyperViewer.markdown(markdown: '# Hello\n\n**Bold** and _italic_.')final captureKey = GlobalKey();
HyperViewer(html: articleHtml, captureKey: captureKey)
// Export to PNG bytes
final png = await captureKey.toPngBytes();
// Export with custom pixel ratio
final hd = await captureKey.toPngBytes(pixelRatio: 3.0);HyperViewer(
html: maybeComplexHtml,
fallbackBuilder: (context) => WebViewWidget(controller: _webViewController),
)HyperViewer({
required String html,
String? baseUrl, // resolves relative <img src> and <a href>
String? customCss, // injected after the document's own <style> tags
bool selectable = true,
bool sanitize = true,
List<String>? allowedTags,
HyperRenderMode mode = HyperRenderMode.auto, // sync | virtualized | auto
void Function(String)? onLinkTap,
HyperWidgetBuilder? widgetBuilder, // custom widget injection
WidgetBuilder? fallbackBuilder,
WidgetBuilder? placeholderBuilder,
GlobalKey? captureKey,
bool showSelectionMenu = true,
String? semanticLabel,
HyperViewerController? controller,
void Function(Object, StackTrace)? onError,
})
HyperViewer.delta(delta: jsonString, ...)
HyperViewer.markdown(markdown: markdownString, ...)final ctrl = HyperViewerController();
HyperViewer(html: html, controller: ctrl)
ctrl.jumpToAnchor('section-2'); // scroll to <a name="section-2">
ctrl.scrollToOffset(1200); // absolute pixel offsetReplace any HTML element with an arbitrary Flutter widget:
HyperViewer(
html: html,
widgetBuilder: (context, node) {
if (node is AtomicNode && node.tagName == 'iframe') {
return YoutubePlayer(url: node.attributes['src'] ?? '');
}
return null; // fall back to default rendering
},
)if (HtmlHeuristics.isComplex(html)) {
// use HyperRenderMode.virtualized for long documents
}
HtmlHeuristics.hasComplexTables(html)
HtmlHeuristics.hasUnsupportedCss(html)
HtmlHeuristics.hasUnsupportedElements(html)HTML / Markdown / Quill Delta
│
▼
ADAPTER LAYER HtmlAdapter · MarkdownAdapter · DeltaAdapter
│
▼
UNIFIED DOCUMENT TREE BlockNode · InlineNode · AtomicNode
RubyNode · TableNode · FlexContainerNode · GridNode
│
▼
CSS RESOLVER specificity cascade · var() · calc() · inheritance
│
▼
SINGLE RenderObject BFC · IFC · Float · Flexbox · Grid · Table
Canvas painting · continuous span tree
Kinsoku · O(log N) binary-search selection
Key engineering decisions:
- Single RenderObject — float layout and crash-free selection require one shared coordinate system; no widget-tree library can provide this
- O(1) CSS rule lookup — rules are indexed by tag / class / ID; constant time regardless of stylesheet size
- O(log N) hit-testing —
_lineStartOffsets[]precomputed at layout time; each touch is a binary search, not a linear scan - RepaintBoundary per chunk — each
ListView.builderchunk gets its own GPU layer; unmodified chunks are composited, not repainted
| Need | Better choice |
|---|---|
| Execute JavaScript | webview_flutter |
| Interactive web forms / input | webview_flutter |
| Rich text editing | super_editor, fleather |
position: fixed, <canvas>, media queries |
webview_flutter (use fallbackBuilder) |
| Maximum CSS coverage, float/CJK not required | flutter_widget_from_html |
| Package | pub.dev | Description |
|---|---|---|
hyper_render |
Convenience wrapper — one dependency, everything included | |
hyper_render_core |
Core engine — UDT model, CSS resolver, RenderObject, design tokens | |
hyper_render_html |
HTML + CSS parser | |
hyper_render_markdown |
Markdown adapter | |
hyper_render_highlight |
Syntax highlighting for <code> / <pre> blocks |
|
hyper_render_clipboard |
Image copy / share via super_clipboard |
|
hyper_render_devtools |
Flutter DevTools extension — UDT inspector, computed styles, demo mode |
git clone https://github.com/brewkits/hyper_render.git
cd hyper_render
flutter pub get
flutter test
dart format --set-exit-if-changed .
flutter analyze --fatal-infosRead the Architecture Decision Records and Contributing Guide before submitting a PR.
MIT — see LICENSE.





