Mobile data visualization in Semiotic is not a separate rendering tier. It is a coordinated set of defaults and contracts acrossChartMode, ChartContainer, annotations, and custom visualization components. The goal is to keep the analytical task intact while reducing chrome, increasing tap affordance, preserving summaries, and letting agents understand what a mobile chart can safely do.
Current implementation status: built-in chart mode, container mobile options, rendered standard controls, annotation mobile policy, custom-child semantic injection, the first mobile interaction contract, and mobile-first small multiples are available for mobile authoring. Tap-to-lock linked-hover detail and helper-based tap-to-select are wired through shared setup at phone widths. A Playwright mobile preview harness now checks 320, 360, 390, 430, and 768 pixel layouts for overflow, painted/labeled canvases, callout-list notes, accessible data summary actions, non-hover detail paths, target sizing, and snapshots. Runtime responsiveRules now feed useChartModeacross built-in HOCs and custom chart scaffolds, reusable chart-family mobile recipes are exported from semiotic/recipes, andChartContainer mobileAudit="warn" exposes the static audit while authoring. Shared mobile behavior covers frame-level background-tap clearing, mobile-aware hit radius, rendered standard controls, and deeper chart-family responsive recipes. Remaining work is frame-specific automatic brush/zoom state synchronization beyond the explicit state-driven pattern.
Interactive demo
Use the controls to compare a phone-width chart against a wider tablet slot. The phone configuration lets ChartContainer injectmode="mobile" into the child chart and passes a mobile semantics object alongside it. The annotation panel uses the same width to show how mobile annotation policies reduce persistent note density without deleting author intent.
Chart width390px
ChartModemobile injected
Annotation budget2 visible
Checkout conversion
Container chrome, chart mode, and semantics adapt together.
Mobile summary: checkout conversion rose from 3.2% to 4.7%. Keep the endpoint and one primary note visible.
Annotation preview
Shrinking the plot below the mobile breakpoint applies the mobile annotation budget. Primary notes stay visible, secondary notes can be deferred, and long copy is replaced by mobileText orshortText.
Notes moved out of the plot
- Weak conversionsecondary
- Mid-volume liftsecondary
ChartMode: mobile
mode="mobile" is the chart-level preset. It is for phone-sized slots where axes still matter, but legends, roomy margins, and hover-first assumptions usually do not. Unlikesparkline, mobile mode keeps labels available by default because phone charts are often the main chart, not decorative context.
Matching responsiveRules now resolve beforeuseChartMode across built-in HOCs and custom chart scaffolds. That means a phone rule can switch tomode="mobile", suppress axes or legends, and providemobileInteraction or mobileSemantics without requiring a separate chart component. Use the chart-family mobile recipes when you want a portable starting point for common phone transforms. Rules currently resolve from chart props and resolved chart size; frame-wide measured container-query behavior remains a strategy item rather than something authors should assume.
mode | "primary" | "context" | "sparkline" | "mobile" | Selects a default chart posture. Mobile sets phone-sized dimensions, tighter margins, hover support where available, no legend by default, and preserved labels. |
responsiveRules | ResponsiveRule[] | Semantic responsive transforms applied before chart mode defaults across built-in HOCs and custom chart scaffolds, covering mobile mode, chrome, mobileInteraction, and mobileSemantics. |
mobileChartFamilyRecipe | "line" | "area" | "ordinal" | "scatter" | "network" | "geo" | "small-multiple" | Recipe helper from semiotic/recipes that returns props, responsiveRules, mobileInteraction, and mobileSemantics with family-specific density, profile, label, annotation, margin, and standard-control defaults. |
mobileBrushAlternatives | (options) => { mobileInteraction, mobileSemantics, controls } | Returns a brush-friendly mobile interaction and semantics bundle so range inputs, chip filters, clear actions, or steppers can sit beside drag gestures. |
MobileStandardControls | { controls, brush, zoom, legend, targetSize } | Rendered touch-sized range, zoom, and legend controls for mobile alternatives to drag, wheel, and hover-heavy interactions. |
useMobileRangeControls | { value, xExtent, brush, zoom, setValue } | State helper for wiring MobileStandardControls to xExtent, filtered data, minimaps, or custom chart range state. |
ChartContainer mobile
ChartContainer owns the surrounding mobile experience: title, subtitle, summary, toolbar wrapping, target sizing, scroll fallback, and the semantic contract passed to a single child chart. This is the right layer for behavior that should work for built-in charts and custom charts.
JSX
const mobileSemantics = { primaryTask: "compare_recent_change", preferredInteraction: "tap", summary: "Conversion rose from 3.2% to 4.7% over eight weeks.", risks: ["hover_only_detail", "overcrowded_annotations", "small_touch_targets"], } <ChartContainer title="Checkout conversion" subtitle="ChartContainer owns mobile chrome; the chart owns encoding." actions={{ dataSummary: true, export: true }} mobile={{ breakpoint: 480, chartMode: "mobile", mobileInteraction: true, semantics: mobileSemantics, summary: "Mobile summary: conversion rose 1.5 points in eight weeks.", hideToolbar: false, allowHorizontalScroll: false, }} > <LineChart data={trendData} lineBy="id" xAccessor="week" yAccessor="value" /> </ChartContainer>
MobileChartContainer
MobileChartContainer is the opinionated M3 composition layer. It does not replace ChartContainer; it wraps it and preserves export, copy-config, data summary, notification, loading, and description behavior. The added layer is mobile-specific: summary cards, chip controls, an inline or sheet detail panel, and optionalchartDefaults for chart-specific mobile defaults such as direct labels.
Checkout conversion
A phone-first shell over the same LineChart.
Conversion rose from 3.2% to 4.7%. The mobile view keeps the trend, endpoint, and one primary reading task visible before the plot.
JSX
<MobileChartContainer title="Checkout conversion" subtitle="Opinionated mobile composition on top of ChartContainer." mobileSummary="Conversion rose from 3.2% to 4.7%. The primary mobile task is comparing recent change, not exploring every weekly value." chips={[ { id: "trend", label: "Trend", description: "8 weeks" }, { id: "drivers", label: "Drivers", description: "summary" }, { id: "risk", label: "Risk", description: "small screen" }, ]} detailTitle="Reading notes" detail={ <ul> <li>Keep the title, summary, and data-summary action available.</li> <li>Use chips for coarse task switching instead of a cramped legend.</li> <li>Use chartDefaults for chart-specific direct-label defaults.</li> </ul> } chartDefaults={{ directLabel: true, showLegend: false }} mobileSemantics={mobileSemantics} > <LineChart data={trendData} lineBy="id" xAccessor="week" yAccessor="value" /> </MobileChartContainer>
SmallMultipleChart
SmallMultipleChart is the M4 responsive small-multiple composition. It stacks panels vertically on phones, keeps panel titles and summaries outside the plot, shares y/value extents by default, and can inject linked hover, selection, mobile interaction, and mobile semantics into each child chart. The child chart still owns its mark encoding; the wrapper owns the repeated-panel reading structure.
It now shares the existing LinkedCharts coordination layer: when linkedBy, linkedHover,selection, or a unified legend is requested,SmallMultipleChart provides a LinkedChartswrapper unless it is already inside one. ScatterplotMatrixalready uses LinkedCharts internally for its cross-cell selection store, so both multi-chart approaches now share the same provider model instead of growing parallel coordination systems.
JSX
<SmallMultipleChart items={regionalTrends} valueAccessor="value" sharedExtent linkedBy={["week"]} mobileColumns={1} tabletColumns={2} columns={4} chartHeight={190} mobileSemantics={{ strategy: "small-multiples", summary: "Four regional trend panels share one y extent and stack vertically on phones.", interaction: { primary: "tap", hoverFallback: "tap-to-lock", targetSize: 44, }, }} > {(region, { chartProps }) => ( <LineChart {...chartProps} data={[{ id: region.id, coordinates: region.data }]} lineBy="id" lineDataAccessor="coordinates" xAccessor="week" yAccessor="value" xLabel="Week" yLabel="Conversion %" /> )} </SmallMultipleChart>
Multi-chart interop
Use SmallMultipleChart when every panel repeats the same analytical encoding with shared extents. Use LinkedChartsdirectly when the views are heterogeneous. Use ChartGridfor dashboard layout; it now shares the same mobile column vocabulary through mobileColumns, tabletColumns, andchartDefaults. Use ContextLayout for primary-plus-context views; it now stacks context panels at the mobile breakpoint by default.
Mobile interaction
mobileInteraction is the shared touch contract for built-in charts, custom charts, audits, and portable specs. When a chart is in a phone-sized slot, Semiotic resolves a default policy that treats tap as the non-hover path: linked-hover detail can be locked on tap, selection can be driven from the same linked fields, the intended target size is explicit, and future brush/zoom alternatives can read the same object.
JSX
<Scatterplot data={campaigns} xAccessor="sessions" yAccessor="conversion" linkedHover={{ name: "campaign", fields: ["channel"] }} selection={{ name: "campaign" }} mode="mobile" mobileInteraction={{ tapToSelect: true, tapToLockTooltip: true, clearSelection: "backgroundTap", targetSize: 44, snap: "nearestDatum", standardControls: "all", }} />
| Option | Type | Purpose |
|---|
mobile | boolean | ChartContainerMobileOptions | Enables the mobile container contract. Pass an object when you need breakpoint, summary, semantics, or toolbar control. |
mobile.breakpoint | number | Viewport width used by the container CSS. The default is 480. |
mobile.chartMode | ChartMode | false | Mode injected into a single child chart when the child has not already declared its own mode. Use false when a custom child manages layout itself. |
mobile.mobileInteraction | boolean | MobileInteractionConfig | Touch policy injected into a single child chart when the child has not already declared mobileInteraction. Defaults to true when mobile is enabled. |
mobile.semantics | MobileVisualizationContract | Machine-readable mobile intent, risks, and recommendations. Built-in intelligence and custom charts can consume the same object. |
mobileAudit | "warn" | true | { viewportWidth, targetSize, visible } | Optional ChartContainer authoring audit. Requires chartConfig; logs mobile findings and can render a compact visible warning banner. |
mobile.summary | ReactNode | Short phone summary rendered by the container at the mobile breakpoint. |
mobile.allowHorizontalScroll | boolean | Last-resort fallback for legacy or deliberately wide custom visualizations. Prefer transformed mobile layouts first. |
mobile.hideToolbar | boolean | Hides toolbar actions at the mobile breakpoint when action controls would compete with the reading task. |
mobile.standardControls | "brush" | "zoom" | "legend" | "all" | string[] | MobileStandardControlsProps | Renders mobile-only standard controls in ChartContainer and mirrors the same request into mobileInteraction.standardControls. |
mobileInteraction | boolean | MobileInteractionConfig | Touch-first chart interaction contract. Defaults activate for mode='mobile' or phone-width shared setup; explicit props override. |
mobileInteraction.tapToLockTooltip | boolean | Locks linked-hover detail on tap so tooltip/cross-highlight information is reachable without hover. |
mobileInteraction.tapToSelect | boolean | Uses the linked fields to write a point selection on tap when a selection target is available. |
mobileInteraction.targetSize | number | Declared comfortable touch target in CSS pixels. Shared behavior helpers use it for mobile-aware hit-radius plumbing and audits check it statically. |
mobileInteraction.standardControls | boolean | "brush" | "zoom" | "legend" | "all" | string[] | Declares which complex gestures have rendered alternatives. ChartContainer can render the matching MobileStandardControls UI from mobile.standardControls. |
mobileInteraction.snap | "nearestDatum" | "none" | Declares the intended touch hit-testing model for imprecise input. Nearest-datum snapping is the mobile default. |
Rendered standard controls
MobileStandardControls is the rendered counterpart to thestandardControls mobile interaction contract. It gives brush, zoom, and legend interactions a touch-sized control path instead of relying on drag, wheel, or hover-only gestures.
Weeks in focus
In responsive applications, gate mobile from your layout breakpoint or set mobile.chartMode=false when the child visualization already has its own mobile state. The container breakpoint controls CSS affordances; the chartMode option controls prop injection.
Mobile annotations
Annotations are often the first thing to fail on a phone. Semiotic's mobile policy combines existing placement behaviors rather than adding a parallel annotation system. It can cap visible notes, preserve a minimum count, defer secondary notes through progressive disclosure, prefer short mobile copy, and maintain layer cohesion for related notes.
For charts that need more than one or two explanatory notes, use the pure mobileAnnotationStrategy() recipe. It ranks annotations, keeps the highest-priority notes in the plot, and returns acalloutList for notes that should move into a mobile detail panel, card, or MobileAnnotationCalloutList. This is the mobile equivalent of keeping labels near the data while moving secondary prose out of the collision zone.
JSX
<Scatterplot data={campaignData} xAccessor="sessions" yAccessor="conversion" pointIdAccessor="id" annotations={[ { type: "callout", pointId: "sms", label: "SMS converts best, but sample size is small.", mobileText: "Best rate, small sample", shortText: "Best rate", emphasis: "primary", }, { type: "label", pointId: "display", label: "Display spends heavily for weak conversion.", mobileText: "Weak conversion", emphasis: "secondary", }, ]} autoPlaceAnnotations={{ mobile: { breakpoint: 480, maxAnnotations: 2, minVisible: 1, progressiveDisclosure: true, preferShortText: true, cohesion: "layer", }, }} />
JSX
import { mobileAnnotationStrategy } from "semiotic/recipes" import { MobileAnnotationCalloutList } from "semiotic" const mobileNotes = mobileAnnotationStrategy(annotations, { active: true, strategy: "callout-list", maxPlotAnnotations: 1, maxCalloutItems: 4, preferShortText: true, }) <MobileChartContainer title="Campaign conversion" detailTitle="Additional notes" detail={<MobileAnnotationCalloutList items={mobileNotes.calloutList} />} > <Scatterplot data={campaigns} annotations={mobileNotes.visible} autoPlaceAnnotations={{ mobile: { strategy: "callout-list", maxAnnotations: 1, progressiveDisclosure: true, preferShortText: true, }, }} /> </MobileChartContainer>
mobileAnnotationStrategy | (annotations, config) => { visible, calloutList, deferred, hidden } | Pure recipe for splitting mobile plot annotations from external callout-list notes. Works with built-in and custom visualizations. |
MobileAnnotationCalloutList | React component | Small renderer for the calloutList returned by mobileAnnotationStrategy. Use inside MobileChartContainer detail panels, cards, or custom layouts. |
autoPlaceAnnotations.mobile.strategy | "plot" | "callout-list" | "hybrid" | Shared strategy vocabulary for the in-plot annotation layout and external mobile callout splitting. |
autoPlaceAnnotations.mobile.breakpoint | number | Width at or below which the mobile annotation policy is active. |
autoPlaceAnnotations.mobile.maxAnnotations | number | Maximum persistent note annotations at phone width. |
autoPlaceAnnotations.mobile.minVisible | number | Minimum persistent note count after density filtering. |
autoPlaceAnnotations.mobile.progressiveDisclosure | boolean | Keeps deferred notes available for hover/focus reveal instead of dropping them completely. |
autoPlaceAnnotations.mobile.preferShortText | boolean | Uses annotation.mobileText, then annotation.shortText, before the full label when mobile is active. |
autoPlaceAnnotations.mobile.cohesion | "layer" | Keeps related note layers together when the layout has to shed or place annotations. |
Mobile preview and CI harness
The mobile preview harness is a Playwright integration page rather than a static checklist. It renders ChartContainer mobile,MobileChartContainer, SmallMultipleChart,mobileAnnotationStrategy(), andMobileAnnotationCalloutList at common phone and tablet widths: 320, 360, 390, 430, and 768 pixels.
The gate checks for document and chart overflow, painted and labeled canvases, mobile callout-list output, accessible data-summary actions, standard detail panels that prevent hover-only reading, explicit touch targets, critical SVG label overlap, console/runtime errors, and a screenshot baseline for each width. Future checks can extend the same harness with chart-family-specific assertions.
BASH
npm run check:mobile-visualization
Custom visualizations are first-class
Mobile support should not stop at the built-in chart components.ChartContainer mobile injects the samemode, mobileInteraction, andmobileSemantics props into a single custom React child. That child can render SVG, Canvas, HTML, WebGL, or a recipe-driven layout and still participate in the same mobile, accessibility, and intelligence contract.
Custom mobile visualization
The child receives mode and mobileSemantics from ChartContainer.
Custom summary: four operational signals are converted into larger tappable bars.
Received mode:mobile.Semantic summary:Four operational signals are shown as tappable bars. Mobile mode stacks labels and increases target height.
JSX
function CustomMobileGlyphChart({ mode, mobileSemantics, data }) { const isMobile = mode === "mobile" return ( <svg role="img" aria-label={mobileSemantics?.summary}> {data.map((d, i) => ( <g key={d.label} transform={isMobile ? `translate(0, ${i * 46})` : `translate(${i * 150}, 0)`}> <rect width={isMobile ? 280 : 120} height={isMobile ? 32 : 88} rx={12} /> <text>{d.label}</text> </g> ))} </svg> ) } <ChartContainer title="Custom mobile visualization" mobile={{ chartMode: "mobile", mobileInteraction: true, semantics: { summary: "Custom marks receive the same mobile contract as built-in charts.", customVisualization: true, }, }} > <CustomMobileGlyphChart data={signals} /> </ChartContainer>
This is the interoperability path for advanced visualization work: custom charts opt into the same semantics object that powers audit, recommendation, generated UI, and future MCP/agent wiring. If the custom child wants total control, passmobile={{ chartMode: false }}and read mobileSemantics only.
Practical rules
Preserve the task
Do not shrink a desktop dashboard until it fits. Decide whether the phone task is comparison, monitoring, lookup, or explanation, then choose the chart mode and annotation budget around that task.
Prefer transformation over scroll
Horizontal scrolling is useful for legacy content and dense timelines, but it should be an explicit fallback. Start with mobile mode, shorter annotations, fewer visible notes, and direct labels.
Make intelligence portable
Put mobile assumptions in mobileSemantics. That makes a custom chart explainable to audits, assistants, generated UI, and downstream renderers instead of hiding the rationale in component code.
Related pages
- Chart Container for toolbar, export, summaries, loading, error, and notification chrome.
- Chart Modes for primary, context, sparkline, and mobile chart presets.
- Annotations for placement, density, provenance, lifecycle, and responsive annotation behavior.
- Custom Charts for the extension points that should consume
mobileSemantics.