diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php
index 51a1388dfd..ea091d0b00 100644
--- a/src/wp-includes/formatting.php
+++ b/src/wp-includes/formatting.php
@@ -2465,17 +2465,47 @@ function force_balance_tags( $text ) {
 	// WP bug fix for LOVE <3 (and other situations with '<' before a number)
 	$text = preg_replace( '#<([0-9]{1})#', '&lt;$1', $text );
 
-	while ( preg_match( '/<(\/?[\w:]*)\s*([^>]*)>/', $text, $regex ) ) {
+	$tag_pattern = (
+		'#<' . // start with an opening bracket
+		'(/?)' . // Group 1 - if it's a closing tag it'll have a leading slash
+		'(' . // Group 2 - tag name
+			// custom element tags have more lenient rules than HTML tag names
+			'(?:[a-z0-9]+(?:-[a-z0-9-]+)*)' .
+				'|' .
+			// traditional tag rules approximate HTML tag names
+			'(?:[\w:]+)' .
+		')' .
+		'(?:' .
+			// we _either_ immediately close the tag with its '>' and have nothing here
+			'\s*' .
+			'(/?)' . // Group 3 - "attributes" for empty tag
+				'|' .
+			// _or_ we must start with space characters to separate the tag name from the attributes (or whitespace)
+			'(\s+)' . // Group 4 - pre-attribute whitespace
+			'([^>]*)' . // Group 5 - attributes
+		')' .
+		'>#' // end with a closing bracket
+	);
+
+	while ( preg_match( $tag_pattern, $text, $regex ) ) {
+		$full_match        = $regex[0];
+		$has_leading_slash = ! empty( $regex[1] );
+		$tag_name          = $regex[2];
+		$tag               = strtolower( $tag_name );
+		$is_single_tag     = in_array( $tag, $single_tags );
+		$pre_attribute_ws  = isset( $regex[4] ) ? $regex[4] : '';
+		$attributes        = trim( isset( $regex[5] ) ? $regex[5] : $regex[3] );
+		$has_self_closer   = '/' === substr( $attributes, -1 );
+
 		$newtext .= $tagqueue;
 
-		$i = strpos( $text, $regex[0] );
-		$l = strlen( $regex[0] );
+		$i = strpos( $text, $full_match );
+		$l = strlen( $full_match );
 
 		// clear the shifter
 		$tagqueue = '';
 		// Pop or Push
-		if ( isset( $regex[1][0] ) && '/' == $regex[1][0] ) { // End Tag
-			$tag = strtolower( substr( $regex[1], 1 ) );
+		if ( $has_leading_slash ) { // End Tag
 			// if too many closing tags
 			if ( $stacksize <= 0 ) {
 				$tag = '';
@@ -2501,21 +2531,16 @@ function force_balance_tags( $text ) {
 				$tag = '';
 			}
 		} else { // Begin Tag
-			$tag = strtolower( $regex[1] );
-
 			// Tag Cleaning
-
-			// If it's an empty tag "< >", do nothing
-			if ( '' == $tag ) {
-				// do nothing
-			} elseif ( substr( $regex[2], -1 ) == '/' ) { // ElseIf it presents itself as a self-closing tag...
+			if ( $has_self_closer ) { // If it presents itself as a self-closing tag...
 				// ...but it isn't a known single-entity self-closing tag, then don't let it be treated as such and
 				// immediately close it with a closing tag (the tag will encapsulate no text as a result)
-				if ( ! in_array( $tag, $single_tags ) ) {
-					$regex[2] = trim( substr( $regex[2], 0, -1 ) ) . "></$tag";
+				if ( ! $is_single_tag ) {
+					$attributes = trim( substr( $attributes, 0, -1 ) ) . "></$tag";
 				}
-			} elseif ( in_array( $tag, $single_tags ) ) { // ElseIf it's a known single-entity tag but it doesn't close itself, do so
-				$regex[2] .= '/';
+			} elseif ( $is_single_tag ) { // ElseIf it's a known single-entity tag but it doesn't close itself, do so
+				$pre_attribute_ws = ' ';
+				$attributes .= '/';
 			} else { // Else it's not a single-entity tag
 				// If the top of the stack is the same as the tag we want to push, close previous tag
 				if ( $stacksize > 0 && ! in_array( $tag, $nestable_tags ) && $tagstack[ $stacksize - 1 ] == $tag ) {
@@ -2526,12 +2551,12 @@ function force_balance_tags( $text ) {
 			}
 
 			// Attributes
-			$attributes = $regex[2];
-			if ( ! empty( $attributes ) && $attributes[0] != '>' ) {
-				$attributes = ' ' . $attributes;
+			if ( $has_self_closer && $is_single_tag ) {
+			    // we need some space - avoid <br/> and prefer <br />
+				$pre_attribute_ws = ' ';
 			}
 
-			$tag = '<' . $tag . $attributes . '>';
+			$tag = '<' . $tag . $pre_attribute_ws . $attributes . '>';
 			//If already queuing a close tag, then put this tag on, too
 			if ( ! empty( $tagqueue ) ) {
 				$tagqueue .= $tag;
diff --git a/tests/phpunit/tests/formatting/balanceTags.php b/tests/phpunit/tests/formatting/balanceTags.php
index 783c320780..aeb02d15ff 100644
--- a/tests/phpunit/tests/formatting/balanceTags.php
+++ b/tests/phpunit/tests/formatting/balanceTags.php
@@ -68,12 +68,15 @@ class Tests_Formatting_BalanceTags extends WP_UnitTestCase {
 			'<em />',
 			'<p class="main1"/>',
 			'<p class="main2" />',
+			'<STRONG/>',
 		);
 		$expected = array(
 			'<strong></strong>',
 			'<em></em>',
 			'<p class="main1"></p>',
 			'<p class="main2"></p>',
+			// Valid tags are transformed to lowercase.
+			'<strong></strong>',
 		);
 
 		foreach ( $inputs as $key => $input ) {
@@ -221,4 +224,56 @@ class Tests_Formatting_BalanceTags extends WP_UnitTestCase {
 		}
 	}
 
+	/**
+	 * Get custom element data.
+	 *
+	 * @return array Data.
+	 */
+	public function data_custom_elements() {
+		return array(
+			// Valid custom element tags.
+			array(
+				'<my-custom-element>Test</my-custom-element>',
+				'<my-custom-element>Test</my-custom-element>',
+			),
+			array(
+				'<my-custom-element>Test',
+				'<my-custom-element>Test</my-custom-element>',
+			),
+			array(
+				'Test</my-custom-element>',
+				'Test',
+			),
+			array(
+				'</my-custom-element>Test',
+				'Test',
+			),
+			// Invalid (or at least temporarily unsupported) custom element tags.
+			array(
+				'<MY-CUSTOM-ELEMENT>Test',
+				'<MY-CUSTOM-ELEMENT>Test',
+			),
+			array(
+				'<my->Test',
+				'<my->Test',
+			),
+			array(
+				'<--->Test',
+				'<--->Test',
+			),
+		);
+	}
+
+	/**
+	 * Test custom elements.
+	 *
+	 * @ticket 47014
+	 * @dataProvider data_custom_elements
+	 *
+	 * @param string $source   Source.
+	 * @param string $expected Expected.
+	 */
+	public function test_custom_elements( $source, $expected ) {
+		$this->assertEquals( $expected, balanceTags( $source, true ) );
+	}
 }
