This document describes an approach for migrating certain prop-drilled visual properties from inline React/OverReact styles to CSS custom properties (CSS variables). It also lists candidates for this migration and explains when the approach is safe versus when it risks introducing bugs.
scadnano follows a React/Redux architecture where the view is a pure function of props, and props are drilled down from connected components. This is the correct default — it ensures React knows when to re-render and prevents the class of bugs that React was designed to eliminate (stale views, forgotten updates, inconsistent state).
However, some visual properties have characteristics that make them safe — and even beneficial — to manage via CSS custom properties instead of React props:
- They are uniform: the same value applies to every instance of a component sharing a CSS class.
- They are purely stylistic: they affect only appearance (font-size, stroke-width, opacity), not layout decisions, conditional rendering, or business logic.
- They are already defined in CSS: the CSS file already has rules for the relevant classes, and the inline style merely overrides or supplements what CSS could express.
Instead of prop-drilling a value like modification_font_size from AppUIState through
DesignMainStrands → DesignMainStrand → DesignMainStrandModifications → DesignMainStrandModification
and finally applying it as ..fontSize = props.font_size, we:
-
Define a CSS variable in the stylesheet with a fallback:
.modification-text { font-size: var(--modification-font-size, 12); }
-
Set the variable on the document root via middleware when the action is dispatched:
document.documentElement?.style.setProperty('--modification-font-size', '$font_size');
-
Set the initial value in
app.dart'sstart()method (aftersetup_view()), reading from the persisted state:set_modification_font_size(store.state.ui_state.modification_font_size); -
Remove the inline style from the React component and remove the prop from every intermediate component in the drilling chain.
The state field, action, reducer, menu item, and serializer registration all remain unchanged — only the view-side plumbing changes.
A CSS variable migration is safe when all of the following hold:
-
Uniform value: Every element with the CSS class gets the same value. There is no per-instance variation. If strand A's domain name font size could differ from strand B's, this approach breaks.
-
No conditional rendering depends on the value: The component does not use the value in
render()to decide what to render (e.g.,if (font_size > 10) show_label()). It only uses the value to set a style attribute. If the value influences control flow, it must remain a React prop so that React re-renders when it changes. -
No derived layout calculations: The component does not use the value to compute positions, sizes, or other attributes that feed into sibling components. For example, if a font-size were used to calculate the vertical offset of a label to avoid overlap, removing it from props would break that calculation.
-
The CSS class is stable: The class name is reliably present on the element and won't be removed or changed by other code paths.
Do not use CSS variables when:
-
The value varies per instance: e.g., each strand has its own color. Strand colors are data-driven, not uniform, so they must remain inline.
-
Render logic depends on the value: e.g.,
show_domain_namesis a boolean that controls whether name text elements are rendered at all. If this were a CSS variable controllingdisplay: none, React would still render the component and its children (wasting work), and any side effects incomponentDidMountwould still fire. Keep booleans that control rendering as React props. -
The value feeds into position/geometry calculations: e.g., DNA sequence font sizes are computed dynamically based on insertion length and letter spacing. These calculations happen in Dart code during
render(), so they cannot be replaced by CSS. -
The component's identity or key depends on the value: Changing a CSS variable doesn't trigger React's reconciliation, so if React needs to know about the change to update keys or diff correctly, it must be a prop.
-
No unnecessary re-renders: Changing a CSS variable does not trigger React re-rendering of any component. Given scadnano's known performance sensitivity to OverReact re-renders (see CONTRIBUTING.md discussion of
identical()vs==), this is significant. Changing a font-size prop currently causes every strand component to re-render even though only a CSS property changed. -
Eliminates prop drilling: For
modification_font_size, the prop passes through 4 intermediate components that don't use it. Removing this simplifies each component's props interface and reduces the surface area for bugs when refactoring. -
CSS handles cascading naturally: The selected-state stroke-width (
calc(var(--strand-stroke-width) + 1)) is expressed purely in CSS relative to the base value. With props, this relationship would need to be duplicated in Dart code. -
Consistent with CSS's purpose: These are presentation-layer concerns. CSS variables are the standard mechanism for parameterizing presentation.
-
Risk: The view is no longer a pure function of React props for these properties. If a CSS variable is not set (e.g., middleware doesn't fire, or initialization is missed), the fallback value in
var(..., fallback)is used. Always provide a sensible fallback matching the constant default. -
Risk: Debugging is harder — you can't inspect a component's props to see the current font-size. Instead, you'd inspect the computed CSS. Mitigation: the value is still in
AppUIStateand visible in Redux DevTools. -
Risk: If a future feature needs per-instance variation for a property that was migrated to CSS, the migration would need to be reversed. Mitigation: only migrate properties that are inherently uniform by design (not just currently uniform by coincidence).
These properties are drilled through 3–5 intermediate components that don't use the value:
| Property | CSS Variable | Default | CSS Classes | Drilling Depth |
|---|---|---|---|---|
modification_font_size |
--modification-font-size |
12.0 | .modification-text |
5 components |
strand_name_font_size |
--strand-name-font-size |
16.0 | .strand-name |
5 components |
strand_label_font_size |
--strand-label-font-size |
16.0 | .strand-label |
5 components |
domain_name_font_size |
--domain-name-font-size |
10.0 | .domain-name, .loopout-name, .extension-name |
5 components |
domain_label_font_size |
--domain-label-font-size |
10.0 | .domain-label, .loopout-label, .extension-label |
5 components |
| Property | CSS Variable | Default | CSS Classes | Drilling Depth |
|---|---|---|---|---|
major_tick_offset_font_size |
--major-tick-offset-font-size |
12.0 | (needs new class) | 3 components |
major_tick_width_font_size |
--major-tick-width-font-size |
8.0 | (needs new class) | 3 components |
| Property | CSS Variable | Default | CSS Classes |
|---|---|---|---|
stroke_width |
--strand-stroke-width |
5.0 | .domain-line, .loopout-curve, .crossover-curve, .extension-line, .insertion-curve, .potential-segment, .potential-vertical-crossover-curve, .domain-line-moving |
crossover_opacity |
--crossover-opacity |
0.5 | .crossover-curve |
crossover_opacity_same_helix |
--crossover-opacity-same-helix |
0.5 | .crossover-curve-same-helix |
These should remain as React props or inline styles:
| Property | Reason |
|---|---|
Strand/domain colors (stroke) |
Per-instance, data-driven |
show_dna, show_modifications, etc. |
Booleans controlling whether components render at all |
| DNA sequence font sizes | Computed per-insertion/loopout based on length and letter spacing |
| Selection state classes | React must manage these for event handling to work correctly |
| Zoom-dependent stroke widths (selection box) | Computed dynamically from current zoom level |
For each candidate, follow these steps:
-
Add
font-size: var(--variable-name, default)to the CSS class inscadnano-styles.css. -
Add a setter function in
lib/src/middleware/stroke_width.dart(consider renaming this file tocss_variables.dartif migrating multiple properties). -
Add middleware handling for the existing action (e.g.,
ModificationFontSizeSet) in the same middleware function to call the setter. -
Add initialization in
app.dart'sstart()method aftersetup_view(). -
Remove the inline style (e.g.,
..fontSize = props.font_size) from the leaf component. -
Remove the prop from every intermediate component in the drilling chain. This is the main payoff — simplifying the component interfaces.
-
Test: verify the value applies correctly on load, changes via the menu, and persists across page refreshes (via localStorage). Also verify that the SVG export includes the correct computed style (the
clone_and_apply_style_recfunction copies computed styles, so CSS variables should resolve correctly during export).