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.13.1 // 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!)), )
dependencies: hyper_render: ^1.13.1
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 RenderObject¹ | ||
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 | ✅ | ✅ | ✅ |
¹ 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> ''')
CSS custom properties work in <style> blocks and customCss, not just inline
(since 1.10.0), and url() / calc() too:
HyperViewer( html: '<p>Themed text</p>', customCss: ':root { --brand: #6750A4; } p { color: var(--brand); padding: calc(8px * 2); }', )
Add hyper_render_devtools to
inspect any HyperViewer in Flutter DevTools: the UDT tree and computed styles,
layout/paint timing per renderer (each virtualized chunk is one), a
selection debugger (fragment boundaries, ruby offsets), live --var
editing that re-renders instantly, and a JSON snapshot export.
void main() { assert(() { HyperRenderDevtools.register(); return true; }()); // debug only runApp(const MyApp()); }
Under a dark Theme, unstyled text uses colorScheme.onSurface and re-resolves when the theme toggles (scroll position kept). Elements that paint their own light background (<blockquote>, <kbd>, <th>, style="background:#eee") keep readable text automatically.
// A surface that stays light under a dark theme (an email pane, a paper page): HyperViewer(html: html, textColor: Colors.black87)
Links, <h6>, <code> / <pre> and <mark> switch to dark-surface variants (at least 4.5:1 against #121212) whenever the effective text color is light — a dark Theme, or a light textColor. A dark Theme with a dark textColor (a white email pane) keeps the light-surface palette. Your own CSS (a { color }, inline style) still wins. <blockquote>, <kbd> and table headers keep their light backgrounds; their text stays readable but they remain light blocks on a dark page.
textColor wins over the content's own html / :root / body color (so an app can force a reader theme over a publisher stylesheet) but not over an element's own color. body { color } and html { color } are honoured; other body properties are not.
<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, // lower priority than the document's own <style> tags (they win at equal specificity; use !important to force) Color? textColor, // document text color; wins over html/body/:root color, not over element colors 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: (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 |
Convenience wrapper — HTML, Markdown, Delta, syntax highlight | |
hyper_render_core |
Core engine — UDT model, CSS resolver, RenderObject; zero native deps | |
hyper_render_html |
HTML + CSS parser | |
hyper_render_markdown |
Markdown adapter (GFM) | |
hyper_render_highlight |
Syntax highlighting for <code> / <pre> blocks |
|
hyper_render_devtools |
Flutter DevTools extension — UDT inspector, computed styles, layout fragments, layout/paint timeline, selection debugger, live CSS-variable editing, JSON snapshot export |
These packages bring specialized dependencies and are not bundled by default. Install only what you need.
| Package | pub.dev | Description |
|---|---|---|
hyper_render_epub |
Full EPUB 2/3 container reader with chapter pagination & in-memory zip decoding | |
hyper_render_clipboard |
Native image copy / share via super_clipboard |
|
hyper_render_math |
LaTeX / MathML via flutter_math_fork |
dependencies: hyper_render_epub: ^0.1.3
import 'package:hyper_render_epub/hyper_render_epub.dart'; // 1. Open the .epub (any Uint8List: File, asset, network) final book = await EpubBook.open(epubBytes); // 2. A controller holds the position; dispose() it when done final controller = EpubReaderController(book: book); // 3. Render the current chapter; drive next()/previous()/goTo() from your own UI EpubReader( controller: controller, textColor: null, // null follows the theme; set it for a sepia/paper page )
dependencies: hyper_render_clipboard: ^1.7.3
import 'package:hyper_render_clipboard/hyper_render_clipboard.dart'; // HyperViewer has no clipboard parameter: hand images to HyperImage yourself. HyperViewer( html: html, widgetBuilder: (node) { if (node is AtomicNode && node.tagName == 'img' && node.src != null) { return HyperImage(src: node.src!, clipboardHandler: SuperClipboardHandler()); } return null; }, )
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.





