Font Scaling Algorithm
This guide explains the dynamic font scaling algorithm used in the Retro Floppy component to automatically fit text within the label area.
Overview
The font scaling algorithm ensures that label text always fits within the available space while maintaining readability. It uses CSS transforms for smooth, performant scaling.
Algorithm Steps
1. Measure Natural Text Width
First, we measure how wide the text would be at its natural (unscaled) size:
// Temporarily remove any transforms
element.style.transform = 'none';
// Measure the natural width
const textWidth = element.scrollWidth;
// Restore transforms
element.style.transform = originalTransform;
Why remove transforms? Transforms affect measurements, so we need to temporarily remove them to get the true text width.
2. Calculate Available Width
The label area has a fixed width based on the disk size. We reserve 12% for padding:
const containerWidth = linesContainer.offsetWidth;
const availableWidth = containerWidth * 0.88; // 88% usable, 12% padding
Why 88%? This provides visual breathing room (6% padding on each side) and prevents text from touching the edges.
3. Calculate Scale Factor
Divide available width by natural text width:
const scale = availableWidth / textWidth;
Examples:
- Text width: 100px, Available: 200px → Scale: 2.0 (expand)
- Text width: 200px, Available: 100px → Scale: 0.5 (compress)
- Text width: 100px, Available: 100px → Scale: 1.0 (perfect fit)
4. Clamp to Readable Range
Limit the scale to maintain readability:
const finalScale = Math.max(0.4, Math.min(1.5, scale));
Scale Bounds:
- Minimum (0.4): Prevents text from becoming illegible
- Maximum (1.5): Prevents excessive stretching that looks distorted
- Default (1.0): Natural size when no scaling needed
5. Apply Transform
Use scaleX transform for horizontal compression/expansion:
element.style.transform = `scaleX(${finalScale})`;
element.style.transformOrigin = 'center center';
Why scaleX? Horizontal scaling maintains vertical readability better than uniform scaling.
Design Decisions
Only Scale Name Field
The algorithm only scales the name field (first line), not the author field:
if (index !== NAME_LINE_INDEX) {
newScales[index] = 1.0; // Author stays at natural size
return;
}
Rationale:
- Maintains visual hierarchy (name is primary, author is secondary)
- Prevents author text from becoming too small
- Creates clear distinction between fields
Use scaleX Instead of font-size
Advantages of scaleX:
- ✅ GPU-accelerated (faster rendering)
- ✅ Doesn't trigger layout reflow
- ✅ Precise scaling (not limited to discrete font sizes)
- ✅ Easier to animate if needed
Disadvantages of font-size:
- ❌ Triggers layout reflow (slower)
- ❌ Limited to discrete sizes (12px, 11px, 10px)
- ❌ Affects line-height and vertical spacing
Use useLayoutEffect
useLayoutEffect(() => {
calculateScales();
}, [labelLines, sizeInPx]);
Why useLayoutEffect? Runs synchronously before browser paint, preventing flash of unstyled content (FOUC).
Alternative (useEffect): Would run after paint, causing visible text size jump.
Visual Examples
Short Text (Scale > 1.0)
Label: "Hi"
Natural width: 30px
Available width: 200px
Scale: 200 / 30 = 6.67
Clamped: min(1.5, 6.67) = 1.5
Result: Text expands to 150% (45px)
Medium Text (Scale ≈ 1.0)
Label: "My Floppy Disk"
Natural width: 180px
Available width: 200px
Scale: 200 / 180 = 1.11
Clamped: 1.11 (within bounds)
Result: Text slightly expands to 111% (200px)
Long Text (Scale < 1.0)
Label: "My Super Long Floppy Disk Label Name"
Natural width: 500px
Available width: 200px
Scale: 200 / 500 = 0.4
Clamped: max(0.4, 0.4) = 0.4
Result: Text compresses to 40% (200px)
Very Long Text (Scale < 0.4)
Label: "This is an extremely long label that exceeds limits"
Natural width: 800px
Available width: 200px
Scale: 200 / 800 = 0.25
Clamped: max(0.4, 0.25) = 0.4
Result: Text compresses to 40% (320px) - overflows slightly
Performance Considerations
Measurement Cost
Measuring text width requires:
- Removing transform
- Reading
scrollWidth(triggers layout) - Restoring transform
Cost: ~1-2ms per label line
Optimization: Only runs when labelLines or sizeInPx changes
Transform Cost
Applying scaleX transform:
- GPU-accelerated
- No layout reflow
- ~0.1ms per frame
Total Cost: Negligible for typical use cases
Batch Updates
The algorithm processes all lines in a single useLayoutEffect:
lineRefs.current.forEach((lineRef, index) => {
// Calculate scale for each line
newScales[index] = calculateScale(lineRef);
});
// Single state update for all lines
setFontScales(newScales);
This prevents multiple re-renders.
Edge Cases
Empty Label
if (!lineRef || !labelLines[index]) {
newScales[index] = 1.0; // Default scale
return;
}
Missing Container
const linesContainer = lineRef.parentElement;
if (!linesContainer) {
newScales[index] = 1.0; // Fallback
return;
}
Zero Width Container
If container width is 0, scale defaults to 1.0 to prevent division by zero.
Customization
Adjust Scale Bounds
Modify constants in FloppyDisk.tsx:
const FONT_SCALE_MIN = 0.4; // Minimum compression
const FONT_SCALE_MAX = 1.5; // Maximum expansion
Adjust Padding
Modify the width ratio:
const LABEL_WIDTH_RATIO = 0.88; // 88% usable, 12% padding
Disable Scaling
Set both min and max to 1.0:
const FONT_SCALE_MIN = 1.0;
const FONT_SCALE_MAX = 1.0;