Building an online vision screening tool sounds simple at first: display letters, ask the user whether they can read them, and progressively reduce the letter size.
In practice, building something usable across desktop and mobile browsers involves much more than that.
I recently worked on PeekVision, a browser-based visual acuity and color vision screening application built with Flutter. One of the most interesting parts of the project was making the test usable across different devices while also adding an accessible, voice-assisted screening option.
You can try the project here:
Methodology and limitations:
https://www.peekvision.io/methodology.html
The Goal
The goal of PeekVision is to provide a simple preliminary browser-based vision screening experience.
The application currently includes:
- Distance visual acuity screening
- Near / reading vision screening
- Separate right-eye, left-eye, and both-eyes testing
- Color vision screening
- An accessible vision test
- Voice-assisted responses
- A screening report
- Mobile and desktop browser support
It is important to emphasize that this is a screening tool, not a diagnostic medical device.
Screen size, browser zoom, display scaling, viewing distance, lighting, and device configuration can affect the results.
Why Browser-Based Vision Testing Is Difficult
Traditional eye charts are designed around a known physical size and a known viewing distance.
A browser does not give us that certainty.
Two devices may both report a width of 400 logical pixels, but the physical size of those pixels can be very different.
For example:
- A phone
- A tablet
- A laptop
- A desktop monitor
may all render the same Flutter widget differently in physical space.
That means browser-based visual acuity should be treated as a screening estimate, especially at very small letter sizes.
For PeekVision, the methodology clearly communicates this limitation instead of presenting browser measurements as clinically calibrated Snellen measurements.
Progressive Visual Acuity Testing
The standard test begins with larger letters and progressively moves toward smaller levels.
A simplified progression looks like this:
static const List<double> steps = [
0.4, // approximately 20/50
0.3, // approximately 20/40
0.2, // approximately 20/32
0.1, // approximately 20/25
0.0, // approximately 20/20
-0.1, // approximately 20/16
-0.2, // approximately 20/13
];
Each row contains five letters.
The user selects:
- I Can Read
- I Can't Read
If they can read the row, the application advances to a smaller level.
If they cannot, the most recent successfully read level is recorded.
The same process is performed for:
- Right eye
- Left eye
- Both eyes
This allows the report to show differences between the two eyes as well as combined vision.
Why Five Letters?
Using multiple letters is more useful than showing a single character because it reduces the chance of a correct response simply through guessing.
PeekVision generates five-character rows using a restricted letter set:
static const String alphabet = 'CDHKNORSVZ';
The row is generated dynamically:
line = List.generate(
5,
(_) => alphabet[random.nextInt(alphabet.length)],
).join(' ');
The exact letters are intentionally not announced during the standard test.
Accessibility Was a Separate Design Problem
A traditional visual-acuity interface can still be difficult for users who have:
- limited vision
- difficulty locating small controls
- motor limitations
- difficulty reading instructions
- screen-reader or voice interaction preferences
Instead of simply enlarging the normal interface, PeekVision includes a separate Accessible Vision Test.
The accessible workflow uses:
- high contrast
- large targets
- simplified controls
- spoken instructions
- speech recognition
- manual fallback controls
The letters themselves are never spoken aloud, because doing so would invalidate the test.
Voice-Assisted Interaction
The accessible mode uses text-to-speech for instructions and speech recognition for user responses.
Conceptually, the flow looks like this:
Speak instruction
↓
Display five letters
↓
Wait for user response
↓
Recognize "I can read" or "I can't read"
↓
Advance to next level
A manual button remains available because microphone permissions and speech recognition support vary between browsers and devices.
This fallback is important for accessibility.
Accessibility should not depend on a single input method.
Responsive Design Became More Important Than Expected
One of the most practical challenges came from mobile screens.
On desktop, the visual acuity setup page had enough room for:
- the test type selector
- vision-correction selector
- test-distance text
- instructions
- the letter row
- response buttons
On a smaller mobile viewport, the same layout could push the letters outside the available area.
The solution was not simply to shrink everything.
Shrinking vision-test letters would directly affect the purpose of the interface.
Instead, the mobile layout changes once the active test begins.
During setup, the user sees:
Test type
Vision correction
Viewing distance
Instructions
Start test
After the user starts testing, those controls are hidden on small screens:
Testing Right Eye
Five-letter row
Screening level
I Can Read
I Can't Read
This frees vertical space while preserving the important testing elements.
A simplified Flutter condition looks like this:
final isMobile =
MediaQuery.of(context).size.width < 600;
final isActivelyTesting =
stage == Stage.testingRight ||
stage == Stage.testingLeft ||
stage == Stage.testingBoth;
if (!(isMobile && isActivelyTesting)) {
// Show configuration controls.
}
This was much better than reducing the letter size.
Preventing Letter Clipping
Even after simplifying the mobile layout, very large letters could potentially exceed their available space.
Using FittedBox provides an additional safety layer:
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
line,
maxLines: 1,
textAlign: TextAlign.center,
),
)
The goal is to prevent accidental clipping while still allowing the intended letter-sizing logic to control the test.
Distance and Near Vision
PeekVision supports two screening modes.
Distance vision
The user is instructed to remain approximately:
10 ft / 3 m
from the screen.
Near vision
The screen is held approximately:
40 cm / 16 in
from the eyes.
The application records which screening mode was used so the report can distinguish near and distance results.
Why Screen Calibration Matters
One of the biggest limitations of online vision tests is physical screen calibration.
CSS pixels, Flutter logical pixels, and physical millimeters are not necessarily equivalent across devices.
A future improvement could allow the user to calibrate the display against a known physical object.
For example:
Place a standard-size card against the screen
↓
Adjust an on-screen rectangle
↓
Calculate pixels per millimeter
↓
Use the value when sizing optotypes
Even with calibration, browser zoom, operating-system scaling, viewing distance, and posture can still affect the result.
That is why the current application clearly describes its result as preliminary screening.
Reporting Results Clearly
A screening application should not simply return:
20/40
without context.
PeekVision reports:
- Right Eye (OD)
- Left Eye (OS)
- Both Eyes (OU)
- Screening level
- Test type
- Correction used
- Test distance
- Comparison with the normal 20/20 reference
- Eye-to-eye comparison
- Screening limitations
For very small letter levels, the report explicitly warns that the result is sensitive to physical display calibration.
Color Vision Screening
PeekVision also includes a color vision screening workflow.
Color screening has a different set of browser limitations:
- brightness
- display color profile
- Night Shift / Night Mode
- True Tone
- blue-light filters
- room lighting
Because of those variables, online color plate results should also be treated as preliminary screening rather than a diagnosis.
A Lesson From Building It
The biggest lesson from this project was that accessibility and responsive design should not be added at the end.
For a visual application, accessibility changes the architecture.
The correct question was not:
How can I make the desktop interface smaller?
It was:
What information does the user actually need at this stage of the test?
That led to a cleaner mobile testing interface while preserving the more detailed setup screen.
Technology
PeekVision is built primarily with:
- Flutter
- Dart
- Flutter Web
- Text-to-speech
- Speech recognition
- Responsive Material UI
The project is designed to run directly in a modern web browser.
Try PeekVision
Live application:
Visual acuity information:
https://www.peekvision.io/visual-acuity-test.html
Accessible vision screening:
https://www.peekvision.io/accessible-vision-test.html
Methodology and limitations:
https://www.peekvision.io/methodology.html
Source project:
https://github.com/pradeepsambamurthy/vision_scanner_flutter
Final Note
Browser-based vision screening can make basic screening more accessible, but it should not be confused with a comprehensive eye examination.
A browser cannot currently reproduce every controlled condition of a professional clinical test.
The interesting engineering challenge is therefore not simply displaying an eye chart.
It is designing a useful screening experience while being transparent about what the technology can and cannot measure.
Top comments (6)
Worth testing with a screen reader running: once Flutter web's semantics is on, a
Textwidget's content is exposed to assistive tech, so VoiceOver or TalkBack can read the five-letter row aloud. That's the one thing the test is designed never to do.Wrapping the row in
ExcludeSemantics, orSemantics(label: 'Letter row', excludeSemantics: true, child: …), keeps it visual-only while the buttons stay announced.Related: Flutter web doesn't build the semantics tree until something asks for it. A screen-reader user first has to find a hidden "Enable accessibility" button. Calling
SemanticsBinding.instance.ensureSemantics()inmain()turns it on from the first frame, which matters for a mode built for people using assistive tech.Glad it helps. One way to check the change landed: with
SemanticsBinding.instance.ensureSemantics()inmain(), the hidden "Enable accessibility" button Flutter web normally shows a screen-reader user disappears, because the tree is already built. That button is the giveaway that semantics is still off.And when you wrap the row, put
ExcludeSemanticsoutside everything that renders a letter, including anySemanticsyou add later for the row itself — an inner label survives an outer exclusion only if it sits on the other side of it. Worth one pass with VoiceOver on the real page, since that's the only way to hear what actually reaches the user.Thanks again for the suggestion. My understanding is that the Accessible Vision Test is specifically intended for users who may have vision-related accessibility needs, while the rest of PeekVision follows the standard test flow. Since ensureSemantics() would enable semantics across the entire app, I’m wondering whether it is necessary to apply it globally, or whether accessibility support can remain focused on the Accessible Vision Test. I’ve already excluded the actual optotype letters from screen-reader semantics so they are not revealed. Please let me know your view if I’m missing something here.
You're not missing much — scoping it works, with one catch.
ensureSemantics()returns a handle, so you can switch it on when the Accessible test opens and dispose it when it closes. The catch is getting there: on Flutter web the rest of the app stays silent to a screen reader until they find the hidden "Enable accessibility" button. If people arrive at the accessible test by a direct link, scoping is enough. If they get there through your home screen, I'd leave it on everywhere — the cost is a layer of DOM, and you've already kept the letters out of it.Thanks, that makes sense. I understand the tradeoff now. I’m going to try enabling semantics when the Accessible Vision Test opens, retain the semantics handle while that screen is active, and dispose it when the user leaves. I’ll keep the optotype letters inside ExcludeSemantics so the screen reader cannot reveal them. I’ll also test the live page with VoiceOver/TalkBack to confirm the navigation and instructions are exposed correctly. If the rest of the site needs better screen-reader navigation later, I can then evaluate enabling semantics more broadly.
Thanks for pointing this out — this is a very useful observation. In PeekVision, the test letters are intentionally not spoken because reading them aloud would reveal the answer, but I hadn’t considered that VoiceOver/TalkBack could still expose the Flutter Text widget through the semantics tree. I’ll update the optotype row to exclude it from screen-reader semantics while keeping the instructions and controls accessible. I’ll also review the ensureSemantics() point you mentioned. Appreciate the detailed feedback.