Alternative text is one of the most important accessibility features available in Power BI, yet it's often overlooked. And that's understandable. We naturally associate data visualisations with visual elements such as colours, bars, lines and charts, so it can be difficult to imagine understanding the insight without seeing the visual itself.
The reality is that not everybody perceives and consumes information in the same way. People who rely on screen readers don't experience a report visually. Instead, they rely on alternative text to understand what a chart is communicating. Good alternative text doesn't simply describe what the chart looks like; it communicates the insight. It tells the user what is happening in the data, what has changed, what stands out, and what action they may need to take.
Because Power BI reports are interactive, that description also needs to evolve with the report. Users apply slicers, filter data, drill into visuals and scheduled refreshes continually update the underlying dataset. A static description written when the report was published can quickly become inaccurate, making dynamic alternative text a far better approach for interactive reports.
Creating meaningful dynamic alternative text, however, often involves repeating the same DAX patterns across multiple visuals and reports. While every visual should ultimately have alternative text tailored to the story it is telling, there's no reason to start from scratch every time.
This is where User-Defined Functions (UDFs) become particularly valuable. They allow us to encapsulate reusable narrative patterns into native DAX functions that can be called from any measure. Whether you're working independently or as part of a larger BI team, you can build your own library of reusable alternative text functions that provide a consistent starting point. The final description can still be customised for each visual, but the underlying logic only needs to be written once.
Understanding the UDF Syntax
A function starts with the FUNCTION keyword, followed by the function name and a list of parameters enclosed in parentheses.
Those parameters define the information the function expects to receive. Rather than reading directly from the data model, the function simply works with whatever values are passed into it. A UDF has no awareness of your tables, columns or filter context. It cannot call SELECTEDVALUE() or MIN() on its own. Every piece of context it needs has to be resolved first and handed to it as a plain value.
Here's a function I use to build a standardised context string, one that describes the current filter state at the top of an alt text description:
DEFINE
/// AltText_Context
/// Standard context string for alt text.
FUNCTION AltText_Context = (
ProductText: STRING,
RegionText: STRING,
StartDate: DATETIME,
EndDate: DATETIME
) =>
"Context: "
& ProductText & ", "
& RegionText & ". "
& "Period between "
& FORMAT ( StartDate, "dd MMM yyyy" )
& " and "
& FORMAT ( EndDate, "dd MMM yyyy" )
& "."
```
Four parameters, four values the function needs before it can do anything: a product description, a region description, a start date and an end date. Notice that ProductText and RegionText aren't the raw column values, they're already-formatted phrases such as "Product: Bikes" or "Region: North West". The UDF isn't building that phrasing itself; it's just assembling four finished pieces of text into one sentence.
Calling the Function from a Measure
This is the part that trips people up first: you can't type AltText_Context(...) directly into a visual's Alt Text property. A UDF only exists as DAX; it has to be called from somewhere, and that somewhere needs to resolve to a single measure that Power BI can actually drop into that property field.
The function's parameters stay as plain STRING and DATETIME values, with no knowledge of where they came from. That's what lets the same AltText_Context function get reused across completely different reports, regardless of what your tables and columns are called.
That means the work of resolving each parameter has to happen outside the function. One way to do that is with variables inside the measure that calls the UDF:
AltText - Context Core =
VAR ProductText =
SELECTEDVALUE ( DimProduct[Product], "all products" )
VAR RegionText =
SELECTEDVALUE ( DimRegion[Region], "all regions" )
VAR StartDate =
MIN ( DimDate[Date] )
VAR EndDate =
MAX ( DimDate[Date] )
RETURN
AltText_Context ( ProductText, RegionText, StartDate, EndDate )
Image 1 shows this measure in action. With Product sliced to Chai and Region sliced to East, the Alt Text Output resolves to "Context: Product: Chai, Region: East. Period between 01 Jan 2025 and 31 Dec 2025." Change the slicer selections and the same measure, and the same underlying function, produces a different sentence without a single line of DAX being rewritten.
Image 1 - A report page with Product and Region slicers, and the resulting Alt Text Output showing what AltText_Context produces when Chai and East are selected: "Context: Product: Chai, Region: East. Period between 01 Jan 2025 and 31 Dec 2025."
Each VAR resolves one parameter, and the RETURN passes them into the function in the exact order it expects. This is the measure you'd actually reference in the Alt Text property, since it's the one that resolves to a finished piece of text.
It's worth being clear, though, that these don't need to be built as variables inside the same measure that calls the UDF. If ProductText or RegionText already exist as their own measures elsewhere in your report, perhaps you're using them on cards or tooltips, you can simply reference those measures directly as arguments instead of recalculating them as VARs:
AltText - Context Core =
AltText_Context ( [Product Text Measure], [Region Text Measure], [Period Start], [Period End] )
Both approaches call the function in exactly the same way. Using VARs keeps everything for that one piece of alt text in a single place; calling existing measures avoids repeating logic you've already built elsewhere. Pick whichever fits how the rest of your model is structured.
If a particular chart doesn't need one of the parameters, say it has no regional breakdown at all, you don't drop the argument, you pass BLANK() in its place:
AltText - Context Core =
AltText_Context ( [Product Text Measure], BLANK(), [Period Start], [Period End] )
The function still receives four arguments in the exact order it expects; it just receives an empty one where there's genuinely nothing to report.
Why This Pattern Works
The immediate benefit is that the same narrative function can be used across multiple visuals. A line chart, bar chart or area chart may all require different calculations, but they often communicate information in remarkably similar ways. Once the narrative has been encapsulated inside a UDF, every measure simply becomes responsible for gathering context, resolving each parameter to a real value, before handing everything over to the function.
This also makes maintaining alternative text much easier. Improvements to wording, formatting or accessibility behaviour only need to be made once, inside the UDF. Every calling measure using that function immediately benefits from the update, without needing to touch the measures themselves.
For larger organisations, this opens another interesting possibility: creating an internal library of accessibility functions that teams can share across projects. Individual developers remain free to tailor the output for specific reports, and to decide which measures feed which parameters, but they begin with a consistent, reusable foundation rather than writing every description from scratch.
Wrapping Up
User-Defined Functions won't replace thoughtful alternative text writing. Every report has a different audience, different business questions and different insights that deserve careful consideration.
What they do provide is an excellent starting point. By separating reusable narrative patterns from report-specific calculations, we can spend less time rewriting the same DAX and more time refining descriptions that genuinely help screen reader users understand our reports.
If you're experimenting with UDFs, I'd recommend starting with a single chart. Build one reusable narrative function, work out which measures you need to supply its parameters, call it, and then see how easily that pattern extends to other visuals.
To help you get started, I've published a growing collection of reusable alternative text User-Defined Functions on GitHub. Feel free to explore them, adapt them to your own reports, or use them as the basis for your own accessibility library.
GitHub repository:
https://github.com/Juls-BI/powerbi-alttext-udfs