Вот решение, которое работает (хотя я не уверен, что понимаю, почему).
<xsl:variable name="unsorted" select="current-group()"/>
<xsl:variable name="sorted">
<xsl:perform-sort select="current-group()">
<xsl:sort select="."/>
</xsl:perform-sort>
</xsl:variable>
<xsl:for-each-group select="$sorted/animal" group-adjacent="ceiling(position() div 3)">
<tr>
<xsl:for-each select="current-group()">
<xsl:apply-templates select="."/>
</xsl:for-each>
</tr>
</xsl:for-each-group>
(Конечно, вам не нужна «несортированная» переменная. Она просто для сравнения)
Странно то, что если я использую $ unsorted в for-each-group, он создает несортированный список, как я и ожидал. Однако, если я использую $ sorted, я должен использовать «$ sorted / animal» по причинам, которые я не совсем понимаю.