I build NutritionFactLabel.com, a nutrition label generator. The first version of our % Daily Value code was one line:
const dv = Math.round((amount / dailyValue) * 100);
It looks right, and for fat, sodium, carbs and fiber it is right. For vitamins and minerals it quietly prints numbers the FDA doesn't allow. Here's the rule, the code we ended up with, and two smaller traps we hit along the way.
The rule: vitamins and minerals use increments
US Nutrition Facts panels are governed by 21 CFR 101.9. For the macronutrients, %DV is rounded to the nearest whole percent. Vitamins and minerals follow a different rule in 101.9(c)(8)(iii):
| Calculated %DV | Declare it as |
|---|---|
| Less than 2% | 0% (or leave the nutrient off where allowed) |
| 2% up to and including 10% | Nearest 2% |
| Above 10% up to and including 50% | Nearest 5% |
| Above 50% | Nearest 10% |
So the same "round it" step has four different step sizes depending on where the number lands.
What Math.round gets wrong
Using the current Daily Values (Iron 18 mg, Calcium 1,300 mg, Potassium 4,700 mg):
| Nutrient | Amount | Raw %DV | Math.round |
FDA |
|---|---|---|---|---|
| Iron | 1.3 mg | 7.2% | 7% | 8% |
| Iron | 1.9 mg | 10.6% | 11% | 10% |
| Calcium | 310 mg | 23.8% | 24% | 25% |
| Calcium | 20 mg | 1.5% | 2% | 0% |
| Potassium | 55 mg | 1.2% | 1% | 0% |
None of these look dramatic on their own. Across a few thousand generated labels, they add up to a lot of non-compliant panels.
The implementation
// Vitamin/mineral %DV per 21 CFR 101.9(c)(8)(iii)
export function vitaminMineralPercent(percent) {
if (!(percent > 0)) return 0;
// Small epsilon so float noise like 4.999999 doesn't round the wrong way
const near = (step) => Math.round(percent / step + 1e-9) * step;
if (percent < 2) return 0;
if (percent <= 10) return near(2);
if (percent <= 50) return near(5);
return near(10);
}
export function calculateDV(nutrient, value, dvTable = DAILY_VALUES, opts = {}) {
const standard = dvTable[nutrient];
if (!standard || standard.value === 0) return 0;
const percent = (value / standard.value) * 100;
if (opts.fda !== false && isVitaminOrMineral(nutrient)) {
return vitaminMineralPercent(percent);
}
return Math.round(percent);
}
Two details worth calling out:
-
The boundaries are inclusive. Exactly 10% stays in the 2% band and exactly 50% stays in the 5% band, so the checks are
<=, not<. -
The epsilon.
percent / stepcan land on values like2.4999999999because of binary floating point. Adding1e-9keeps a true midpoint rounding up the way you'd expect without changing any real value.
Trap 2: a 0 g amount with a non-zero %DV
Amounts get rounded too (fat under 0.5 g is declared as 0 g, sodium under 5 mg as 0 mg, and so on). If you compute %DV from the raw amount, you can end up printing 0 g next to 1%, which reads as a mistake to anyone checking the panel. We force the %DV to 0 whenever the declared amount rounds to zero:
const rounded = roundNutrient(nutrient, raw);
const declaredZero = rounded.display === '0';
const dv = declaredZero ? 0 : calculateDV(nutrient, raw, dvTable);
Trap 3: these rules are American
We generate labels for 152 formats in 114 countries, and other regulators have their own rounding. Canada, for example, has its own Daily Values and rules, so the US increments must not leak into the bilingual Canadian table. That's what the opts.fda === false escape hatch is for: the Canadian renderer opts out and keeps its existing output byte for byte.
How we made sure nothing else moved
Changing a shared formatter in a label generator is scary, because every format depends on it. Before shipping, we:
- Re-rendered every label format and diffed it against a stored snapshot, checking that only the vitamin and mineral %DV numbers changed.
- Ran our logic test suite and checked the boundary cases: 1.99% → 0%, 2% → 2%, 10% → 10%, 10.01% → 10%, 50% → 50%, 50.01% → 50%.
- Regenerated the static label images for our food pages and spot-checked live pages against hand calculations.
If you're building anything that outputs regulated numbers, the snapshot diff is the part I'd copy. It turns "I think this only changed one thing" into something you can prove.
Try it
You can check any value with our free % Daily Value calculator, and the full rule set is written up in our US FDA nutrition label guide.
This post is about implementation, not legal advice. Always confirm label requirements against the current regulation.
Top comments (0)