Design techniques

Power BI table rows as cards: one SVG measure per row

A native table row can't have rounded corners or a highlight. One DAX measure draws an SVG tile per row, and the table shows only that image column.

9 min read

The Colis Pro activity list: four card-like rows that are really a table with one SVG image column, next to its formatting properties

The mockup asked for a list of rounded tiles: an icon, a name, a share bar, a coloured change pill, a chevron, and the item shown in the detail card in yellow. A table behaves the right way (one row per item, sorted, clickable), but a table row can't look like a card.

In Colis Pro, a free Power BI report built with an AI agent, two lists work this way: "Activité par service" on the overview page and "Envois récents" on the shipments page. Each one is a native table whose only visible column is an image. One DAX measure draws that image, an SVG tile, once per row.

This article shows how it works, and how to build it in Power BI Desktop with no custom visual.

Before and after

Before, the services list is a native table with an icon column and text columns. After, each row is a rounded tile with a share bar, a change pill and a chevron, the first one in yellow.
Same table visual, same row field underneath. On the right, everything you see in a row is one image.

Why a native row can't do it

A table formats cells, and a cell is a rectangle:

  • No rounded corners per row. Gridlines and borders are straight lines across the whole table.
  • No transparent cells. A table can't be see-through, so a row can't float on the panel behind it.
  • A fixed layout. Each field is its own column, at the same place in every row. A long text wraps onto two lines, and nothing can sit inside something else: no bar under a name, no pill within a row.
Before, the shipments list has text in columns and two statuses wrap onto two lines. After, each shipment is a tile and the one shown in the detail card is yellow.
On the shipments page, "En retard de 2 jours" and "En transit depuis 1 jour" wrapped. In a tile, the status sits under the number.

The idea: the row is an image

A table column can show images. When a measure's data category is Image URL, the table draws its value as a picture, and that value can be an SVG written in DAX as a data URI. So the measure doesn't return a number. It returns a complete tile for the row it's evaluated in.

The table keeps three fields:

  1. The row field, DimService[Service]. It makes one row per service and gives the measure its row context. Shrunk to 1 px.
  2. The SVG measure, Ligne activité (SVG). The only visible column: 448 px wide for a 440 px tile.
  3. The sort measure, [Envois]. A table sorts by a field it contains, so it stays, at 1 px.
One table rowDimService[Service] = "Pli Éco"row contextLigne activité (SVG)reads [Envois], [Part du total], [Envois écart]concatenatesAn SVG string<svg width='440' height='79'> … </svg>% → %25, then # → %23A data URI, category Image URLdata:image/svg+xml;utf8,…header row: text and fill in the panel colourColis StandardPli ÉcoExpress 24hdrawn in the cell79 pxrow padding 0Service: 1 pxLigne activité (SVG): 448 pxEnvois: 1 px, sortsNot to scale: the two 1 px columns are widened here so they can be seen.

On the overview page, the tabs above the list (Services, Régions, Clients) swap the row field through a field parameter. The same measure then draws a pin for regions and initials for customers.

The measure: one tile per row

Each tile is 440 × 79. The measure reads the row's values, picks colours and concatenates SVG text. Here is the services version, trimmed: the real measure also switches between the three axes, draws an icon per item, adds the volume line under the name, and translates "Autres" to "Others".

DAX
Ligne activité (SVG) =
VAR _nom = SELECTEDVALUE ( DimService[Service] )
VAR _max = CALCULATE ( MAXX ( VALUES ( DimService[Service] ), [Envois] ), REMOVEFILTERS ( DimService ) )
VAR _premier = [Envois] = _max
VAR _part = MAX ( MIN ( [Part du total], 1 ), 0 )
VAR _e = [Envois écart]
VAR _hausse = _e >= 0
VAR _tuile = IF ( _premier, "#FDCB12", "#12376F" )
VAR _encre = IF ( _premier, "#0B1A4A", "#FFFFFF" )
VAR _nomAff = SUBSTITUTE ( IF ( LEN ( _nom ) > 24, LEFT ( _nom, 23 ) & "…", _nom ), "&", "&amp;" )
VAR _ecartTxt = IF ( _hausse, "↑ ", "↓ " ) & SUBSTITUTE ( SUBSTITUTE ( FORMAT ( _e, "+0.0 %;-0.0 %", "fr-FR" ), UNICHAR ( 8239 ), " " ), UNICHAR ( 160 ), " " )
VAR _svg =
    "<svg xmlns='http://www.w3.org/2000/svg' width='440' height='79' viewBox='0 0 440 79'>"
        & "<rect x='0' y='3' width='440' height='73' rx='18' fill='" & _tuile & "'/>"
        & "<text x='74' y='34' font-family='Segoe UI Semibold, Segoe UI, Arial, sans-serif' font-size='15' font-weight='600' fill='" & _encre & "'>" & _nomAff & "</text>"
        & "<rect x='74' y='59' width='150' height='4' rx='2' fill='" & _encre & "' fill-opacity='0.18'/>"
        & "<rect x='74' y='59' width='" & FORMAT ( 150 * _part, "0.0", "en-US" ) & "' height='4' rx='2' fill='" & _encre & "'/>"
        & IF ( NOT ISBLANK ( _e ), "<rect x='300' y='24' width='98' height='31' rx='15.5' fill='" & IF ( _hausse, "#DDF5E7", "#FFE1E6" ) & "'/>"
            & "<text x='349' y='44.5' text-anchor='middle' font-family='Segoe UI, Arial, sans-serif' font-size='12.5' font-weight='700' fill='" & IF ( _hausse, "#0F7A43", "#B4234A" ) & "'>" & _ecartTxt & "</text>" )
        & "<polyline points='414,31 422,39.5 414,48' fill='none' stroke='" & _encre & "' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'/></svg>"
RETURN
    IF ( NOT ISBLANK ( _nom ) && NOT ISBLANK ( [Envois] ),
        "data:image/svg+xml;utf8," & SUBSTITUTE ( SUBSTITUTE ( _svg, "%", "%25" ), "#", "%23" ) )

Every piece is placed by coordinates inside the 440 × 79 box:

Pli Éco64 220 envois · 25,0 %↑ +3,8 %iconcircle, r 22name at x 74, y 34cut after 24 characterschange pill at x 300green up, red downchevronshare bar: width = 150 × [Part du total]a 150 px track at 18 % opacity under itvolume · share at y 52Image 440 × 79, tile 73 px high from y 3: 3 px of panel colour above and below,so 6 px between two tiles. Scale 1.8.

A few lines do more than they seem:

  • y='3' and height='73'. The tile is 6 px shorter than the image. With no gridlines and no row padding, that leaves 6 px of panel colour between two tiles.
  • The share bar is two rectangles: a 150 px track at 18 % opacity, and 150 * [Part du total] on top. The width goes through FORMAT ( …, "0.0", "en-US" ) because a French format would write the decimal with a comma, which is not an SVG number.
  • Names are cut. SVG text never wraps, so a name over 24 characters keeps 23 and gets "…". An & becomes &amp;, or the SVG is not valid XML.
  • The string is encoded. The pill text contains % and every colour contains #, and both mean something in a URI. Replace % with %25 first, then # with %23: in the other order, the % of %23 gets encoded again. Our article on SVG measures in Power BI cards covers this in depth.

The shipments list, Ligne envoi (SVG), uses the same frame with other content: the service icon, the shipment number, a relative status ("En transit depuis 1 jour"), a status pill and the amount. SVG can't measure text, so the pill's width is estimated from the label: 22 + LEN ( _statut ) * 6.6.

Step by step in Power BI Desktop

  1. Write the measure and set its category. Select it, then Measure tools > Data category > Image URL. In the model files (TMDL), the measure gets dataCategory: ImageUrl.
  2. Build the table. Add the row field, the SVG measure and the sort measure. Turn totals off.
  3. Size the images to the SVG. Image height 79, image width 440, row padding 0, horizontal and vertical gridlines off.
  4. Paint the cells the panel colour. In Values, set the background colour, the alternate background colour and both text colours to the colour behind the table: #0E2E61 in Colis Pro. Whatever the tile doesn't cover melts into the panel.
  5. Make the header invisible. Rename the SVG column to a single space, and give the column headers the panel colour for both text and background, at 8 pt.
  6. Set the column widths. 448 px for the SVG column, 1 px for the others. You can drag column edges in Desktop; we wrote the widths in the visual's file.
  7. Sort by the measure. From the visual's More options (…) menu, sort by [Envois], descending. The biggest item comes first, whatever the tab.

The formatting of the overview list, abridged from its visual.json (PBIR):

JSON
"grid": [{ "properties": {
  "gridHorizontal": { "expr": { "Literal": { "Value": "false" } } },
  "gridVertical":   { "expr": { "Literal": { "Value": "false" } } },
  "rowPadding":     { "expr": { "Literal": { "Value": "0L" } } },
  "imageHeight":    { "expr": { "Literal": { "Value": "79L" } } },
  "imageWidth":     { "expr": { "Literal": { "Value": "440L" } } }
}}],
"values": [{ "properties": {
  "backColorPrimary":   { "solid": { "color": { "expr": { "Literal": { "Value": "'#0E2E61'" } } } } },
  "backColorSecondary": { "solid": { "color": { "expr": { "Literal": { "Value": "'#0E2E61'" } } } } }
}}],
"columnWidth": [
  { "properties": { "value": { "expr": { "Literal": { "Value": "1D" } } } },
    "selector": { "metadata": "DimService.Service" } },
  { "properties": { "value": { "expr": { "Literal": { "Value": "448D" } } } },
    "selector": { "metadata": "_Mesures.Ligne activité (SVG)" } },
  { "properties": { "value": { "expr": { "Literal": { "Value": "1D" } } } },
    "selector": { "metadata": "_Mesures.Envois" } }
]

The real file also gives 1 px to DimRegion.Region and DimClient.Client, the other fields the tabs can put in the row. If you write PBIR by hand or with an agent, our list of PBIR validator traps is worth a read.

The yellow tile and the detail card

Each list sits next to a detail card, and the two follow one rule.

The overview panel: the services list on the left with Colis Standard in yellow, and the detail card for Colis Standard on the right.
Colis Standard is yellow in the list and shown in the card: it has the most shipments.

The list filters the card, and only the card. Select the list, open Format > Edit interactions, and set every other visual on the page to None. In the file, each of those is a NoFilter interaction from the list: 26 on the overview page, 25 on the shipments page. Otherwise a click on a tile would also filter or highlight the KPI cards and charts.

The card has a default item. With nothing selected, the card's measures fall back to the top of the ranking. This is the services branch of Détail : nom:

DAX
IF ( HASONEVALUE ( DimService[Service] ), VALUES ( DimService[Service] ),
    MAXX ( TOPN ( 1, VALUES ( DimService[Service] ), [Envois] ), DimService[Service] ) )

The yellow tile uses the same rule. In the list, _premier is true when the row's [Envois] equals the maximum across all services, computed with REMOVEFILTERS ( DimService ). On the shipments page, the list asks the card's own measure: a row is yellow when its number equals CALCULATE ( [Envoi affiché], REMOVEFILTERS ( FactEnvoi[NumeroEnvoi] ) ). [Envoi affiché] returns the selected shipment, or else the most recent one in transit.

The shipments panel: the recent shipments list with CP-2026-1971097 in yellow, and the detail card of that same shipment with its timeline.
CP-2026-1971097 is yellow because it is the most recent shipment in transit, the one the card shows when nothing is selected.

One limit follows: the yellow comes from the measure, not from the click. A visual is not filtered by its own selection, so after a click the card changes and the yellow tile stays on the default item.

NOTHING SELECTEDList: Ligne activité (SVG)yellow when [Envois] = the maximumCard: Détail : nomno single service: TOPN ( 1, …, [Envois] )same ruleColis Standardyellow in the list, shown in the cardAFTER A CLICK ON PLI ÉCOSelection in the listrow Pli ÉcoFilterNoneDetail cardshows Pli Éco26 other visualsEdit interactions: NoneThe list is not filtered by its own selection: its yellow tile stays on Colis Standard.

The traps

  • Two columns with the same name break the query. A single space hides the SVG column's name, but a second column named " " gives "Error fetching data for this visual". On the shipments page, the number column is named N° instead.
  • Keep the sizes in step. The SVG's width='440' height='79', the table's image width and height, and a column a little wider than the image (448). With row padding at 0, each row is as tall as the image: four tiles take 316 px.
  • Leave room for the scrollbar. The shipments list scrolls. Its table is 472 px wide for a 448 px column; narrower, a horizontal scrollbar appeared as well.
  • The header is invisible, not gone. A click on a column header still sorts the table, and the sort is saved with the visual. Check it after editing in Desktop: a stray click changed the order of our shipments list once.
  • The text is pixels. Readers can't select it, a screen reader can't read it as text, and translation has to happen inside the measure: ours switches "Autres" to "Others" when English is chosen (see our bilingual report). The 1 px columns keep real values in the visual, and the tooltips are sentences built from them, such as Envoi {N°}, dernier événement le {Tri}.
  • Limit the rows on a big fact table. The shipments list reads a fact table of about 4 million shipments. It carries a visual-level Top N filter: the 300 most recent, by a hidden timestamp column. Before that filter, the list had not loaded after 50 seconds when the report opened.

Try it

Download Colis Pro, free. The lists are on the Vue d'ensemble and Envois pages, and the measures Ligne activité (SVG) and Ligne envoi (SVG) are in the 96 Activité folder of the _Mesures table. Switch the tabs above the overview list to see the same measure draw regions and customers.

If you want this kind of finish on your own model without writing SVG by hand, ChartCraft generates designed Power BI projects, and its MCP server lets your agent do it on your semantic model.

FAQ

Do I need a custom visual?

No. It is the native table visual plus one DAX measure with the Image URL data category.

Can readers still click a tile?

Yes. A click selects the row like in any table, and Edit interactions decides which visuals it filters.

Does it work in the Power BI Service?

Yes. SVG data URIs returned by an Image URL measure render in the Service as they do in Desktop.

With your consent, we use Google Analytics cookies to measure audience. Cookies needed for sign-in and payment are always on. Learn more