pub.dev pub points likes CI License: MIT Flutter
CSS float · crash-free selection · AI/LLM streaming · CJK/Furigana · @keyframes · 2 490+ tests · XSS-safe · Zero Gradle config
Live Web Playground · Quick Start · AI Streaming · 30s Migration · Why Switch? · API · Packages
Already using flutter_html? You don't need to rewrite your widget tree or learn a new API. Just change your single import:
// 1. In your pubspec.yaml: // dependencies: // hyper_render: ^1.9.0 // 2. In your Dart file — replace this single line: // ❌ import 'package:flutter_html/flutter_html.dart'; import 'package:hyper_render/compat/flutter_html.dart'; // 3. Your existing code works out of the box — rendered by one RenderObject // instead of hundreds of nested widgets (see "Why Switch?" below). Html( data: '<h1>Hello</h1><p>Text wraps seamlessly around floats!</p>', onLinkTap: (url, attributes, element) => launchUrl(Uri.parse(url!)), )
| CSS Float Layout | Ruby / Furigana | Crash-Free Selection |
|---|---|---|
| CSS Float Demo | Ruby Demo | Selection Demo |
| Text wraps around floated images — no other Flutter HTML renderer does this | Furigana centered above base glyphs, full Kinsoku line-breaking | Select across headings, paragraphs, tables — tested to 100 000 chars |
| Advanced Tables | Head-to-Head | Virtualized Mode |
|---|---|---|
| Table Demo | Comparison Demo | Performance Demo |
colspan · rowspan · W3C 2-pass column algorithm |
Same HTML in HyperRender vs flutter_widget_from_html | Virtualized rendering — only visible sections are built and painted |
dependencies: hyper_render: ^1.9.0
import 'package:hyper_render/hyper_render.dart'; HyperViewer( html: articleHtml, onLinkTap: (url) => launchUrl(Uri.parse(url)), )
Zero configuration. XSS sanitization is on by default. No Gradle setup required.
Render live streaming token feeds from Google Gemini, OpenAI ChatGPT, Anthropic Claude, or WebSocket backends with frame-aligned, adaptively-throttled updates and automatic transient syntax repair.
final controller = HyperStreamingController(); // Bind directly to any Dart Stream (e.g. OpenAI / Gemini SDK): controller.bindStream(aiTokenStream); // Render with automatic stick-to-bottom auto-scroller and pulsing caret: HyperViewer.streaming( streamingController: controller, contentType: HyperContentType.markdown, showTypingCaret: true, caretStyle: HyperTypingCaretStyle.bar, autoRepairSyntax: true, // Auto-closes incomplete ```, **, $,ドル | on the fly autoScrollToBottom: true, // Smoothly tracks stream tail )
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 architecturally impossible 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 pass.
HyperRender renders the whole document inside one custom RenderObject. CSS float, crash-free selection, O(log N) binary-search hit-testing, and @keyframes animations all follow directly from that single architectural decision.
| Feature | flutter_html |
flutter_widget_from_html |
HyperRender |
|---|---|---|---|
float: left / right |
❌ | ❌ | ✅ |
| AI / LLM Streaming | ❌ | ❌ | ✅ Frame-aligned, adaptive throttle |
| Text selection — large docs | ❌ Crashes | ❌ Crashes | ✅ Crash-free |
| Ruby / Furigana + Kinsoku | ❌ Raw text | ❌ Raw text | ✅ |
| RTL / BiDi (Arabic, Hebrew) | ✅ | ||
CSS Variables var() |
❌ | ❌ | ✅ |
CSS @keyframes animation |
❌ | ❌ | ✅ |
| Flexbox / Grid | ✅ Wrapping flex on a custom RenderObject1 | ||
box-shadow · filter |
❌ | ❌ | ✅ |
list-style-type (all 11 values) |
✅ | ||
<details> / <summary> |
❌ | ❌ | ✅ Interactive |
| Quill Delta input | ❌ | ❌ | ✅ |
| Markdown input | ❌ | ❌ | ✅ GFM |
| Modular packages | ❌ monolith | ❌ monolith | ✅ opt-in add-ons |
| Zero Gradle config | ✅ | ✅ | ✅ |
1 flex-wrap: wrap on a row container performs real CSS line packing and distributes free space by flex-grow, honouring flex-basis / min-width / max-width including their % forms. Known gaps: align-content is not applied, flex-basis does not drive the nowrap path, and flex-direction: column + wrap packs lines without growth. Per-property status: CSS_PROPERTIES_MATRIX.md.
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 東京(とうきょう).
Responsive wrapping cards — flex-wrap: wrap packs items into lines and shares
each line's free space by flex-grow, so the same markup a browser gets works
here:
HyperViewer(html: ''' <div style="display:flex; flex-wrap:wrap; gap:14px;"> <div style="flex:1 1 220px; min-width:220px;">Card 1</div> <div style="flex:1 1 220px; min-width:220px;">Card 2</div> <div style="flex:1 1 220px; min-width:220px;">Card 3</div> </div> ''')
CSS custom properties and grid:
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; } } @keyframes flash { from { background-color: #fffbdd; } to { background-color: #ffffff; } } .hero { animation: fadeIn 0.6s cubic-bezier(0.4, 0, 0.2, 1); } .card { animation: slideUp 0.4s ease-out; } .tick { animation: flash 1s steps(4, end); } </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, color,
background-color, vendor-prefixed variants, and percentage selectors.
Timing functions (@keyframes, animation, and transition): linear, ease,
ease-in, ease-out, ease-in-out, cubic-bezier(x1, y1, x2, y2), steps(n, start|end),
step-start, and step-end. color / background-color are interpolated for both
@keyframes and transition.
// 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)
Inline SVG note:
<svg>and<math>are stripped by default because inline SVG can embed<script>payloads. External SVG via<img src="*.svg">is fully supported. Add'svg'toallowedTagsonly for content you fully control.
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(); 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 | paged | auto bool enableZoom = false, void Function(String)? onLinkTap, HyperWidgetBuilder? widgetBuilder, // custom widget injection WidgetBuilder? fallbackBuilder, WidgetBuilder? placeholderBuilder, GlobalKey? captureKey, bool showSelectionMenu = true, String? semanticLabel, HyperViewerController? controller, HyperPageController? pageController, // paged mode only HyperPluginRegistry? pluginRegistry, // custom tag plugins void Function(Object, StackTrace)? onError, }) HyperViewer.delta(delta: jsonString, ...) HyperViewer.markdown(markdown: markdownString, ...)
| Value | Behaviour |
|---|---|
auto |
Sync for ≤ 10 000 chars, async virtualized otherwise |
sync |
Always render synchronously in a single scroll view |
virtualized |
ListView.builder — only visible sections built/painted |
paged |
PageView.builder — one section per page (e-book / reader UI) |
final ctrl = HyperPageController(); HyperViewer(html: html, mode: HyperRenderMode.paged, pageController: ctrl) ctrl.nextPage(duration: Duration(milliseconds: 300), curve: Curves.easeInOut); ctrl.animateToPage(2, duration: Duration(milliseconds: 300), curve: Curves.easeInOut); ctrl.jumpToPage(0); // Reactive page indicator: ValueListenableBuilder<int>( valueListenable: ctrl.currentPage, builder: (_, page, __) => Text('Page ${page + 1} of ${ctrl.pageCount}'), )
Register custom tag renderers via HyperPluginRegistry. Two tiers:
- Block (
isInline == false): full-width widget with CSS margins - Inline (
isInline == true): flows with text; intrinsic size measured automatically
class MyCardPlugin implements HyperNodePlugin { @override String get tagName => 'my-card'; @override bool get isInline => false; @override Widget? build(HyperPluginBuildContext ctx) { return Card(child: Text(ctx.node.textContent)); // Return null to fall through to default rendering. } } final registry = HyperPluginRegistry()..register(MyCardPlugin()); HyperViewer(html: '<my-card>Hello</my-card>', pluginRegistry: registry)
final ctrl = HyperViewerController(); HyperViewer(html: html, controller: ctrl) ctrl.scrollToId('section-2'); // scroll to <id="section-2"> ctrl.scrollToOffset(1200); // absolute pixel offset
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
- Single RenderObject — float layout and crash-free selection require one shared coordinate system; a widget tree cannot provide this
- O(1) CSS rule lookup — rules 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 — unmodified chunks are composited, not repainted; incremental layout caches unchanged sections by content hash
- 2 495 passing tests — unit, widget, integration, fuzz (339 seeded-mutation cases), plus 28 golden pixel tests across 3 OS platforms
| 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 |
- Image alt text (WCAG 1.1.1):
<img alt="...">elements produce a discreteSemanticsNodeat the image's layout rect — screen-reader users can navigate to images element-by-element. aria-labelon links (WCAG 4.1.2):<a aria-label="...">uses the attribute value as the accessible label instead of text content.
<img src="chart.png" alt="Q3 revenue chart — 2ドル.4M, up 18% YoY"> <a href="/privacy" aria-label="Privacy policy (opens in new tab)">Privacy</a>
| Package | pub.dev | Description |
|---|---|---|
hyper_render |
pub | Convenience wrapper — HTML, Markdown, Delta, syntax highlight |
hyper_render_core |
pub | Core engine — UDT model, CSS resolver, RenderObject; zero native deps |
hyper_render_html |
pub | HTML + CSS parser |
hyper_render_markdown |
pub | Markdown adapter (GFM) |
hyper_render_highlight |
pub | Syntax highlighting for <code> / <pre> blocks |
hyper_render_devtools |
pub | Flutter DevTools extension — UDT inspector, computed styles, float visualizer |
These packages bring specialized dependencies and are not bundled by default. Install only what you need.
| Package | pub.dev | Description |
|---|---|---|
hyper_render_epub |
pub | Full EPUB 2/3 container reader with chapter pagination & in-memory zip decoding |
hyper_render_clipboard |
pub | Native image copy / share via super_clipboard |
hyper_render_math |
pub | LaTeX / MathML via flutter_math_fork |
dependencies: hyper_render_epub: ^0.1.2
import 'package:hyper_render_epub/hyper_render_epub.dart'; // 1. Open EPUB from file bytes or asset final book = await EpubBook.openBytes(epubBytes); // 2. Render book with chapter navigation EpubReader( book: book, controller: EpubReaderController(), onChapterChanged: (chapter) => print('Now reading: ${chapter.title}'), )
dependencies: hyper_render_clipboard: ^1.7.0
import 'package:hyper_render_clipboard/hyper_render_clipboard.dart'; HyperViewer( html: html, imageClipboardHandler: SuperClipboardHandler(), )
Android setup required:
super_clipboardtransitively pulls inirondash_engine_context, which requirescompileSdk ≥ 34. Add this toandroid/build.gradle.kts(root file, notapp/):subprojects { afterEvaluate { extensions.findByType(com.android.build.gradle.LibraryExtension::class.java)?.apply { compileSdk = 35 } } }Tracked in #5.
dependencies: hyper_render_math: ^1.7.0
import 'package:hyper_render_math/hyper_render_math.dart'; final registry = HyperPluginRegistry()..register(const MathPlugin()); HyperViewer(html: html, pluginRegistry: registry)
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-infos
See Architecture Decision Records and Contributing Guide before submitting a PR.
MIT — see LICENSE.