diff --git src/wp-admin/customize.php src/wp-admin/customize.php
index fc181db..8601032 100644
--- src/wp-admin/customize.php
+++ src/wp-admin/customize.php
@@ -170,7 +170,9 @@ do_action( 'customize_controls_print_scripts' );
 	<div id="customize-preview" class="wp-full-overlay-main"></div>
 	<?php
 
-	// Render control templates.
+	// Render Panel, Section, and Control templates.
+	$wp_customize->render_panel_templates();
+	$wp_customize->render_section_templates();
 	$wp_customize->render_control_templates();
 
 	/**
@@ -254,28 +256,38 @@ do_action( 'customize_controls_print_scripts' );
 
 	// Prepare Customize Setting objects to pass to JavaScript.
 	foreach ( $wp_customize->settings() as $id => $setting ) {
-		$settings['settings'][ $id ] = array(
-			'value'     => $setting->js_value(),
-			'transport' => $setting->transport,
-			'dirty'     => $setting->dirty,
-		);
+		if ( $setting->check_capabilities() ) {
+			$settings['settings'][ $id ] = array(
+				'value'     => $setting->js_value(),
+				'transport' => $setting->transport,
+				'dirty'     => $setting->dirty,
+			);
+		}
 	}
 
 	// Prepare Customize Control objects to pass to JavaScript.
 	foreach ( $wp_customize->controls() as $id => $control ) {
-		$settings['controls'][ $id ] = $control->json();
+		if ( $control->check_capabilities() ) {
+			$settings['controls'][ $id ] = $control->json();
+		}
 	}
 
 	// Prepare Customize Section objects to pass to JavaScript.
 	foreach ( $wp_customize->sections() as $id => $section ) {
-		$settings['sections'][ $id ] = $section->json();
+		if ( $section->check_capabilities() ) {
+			$settings['sections'][ $id ] = $section->json();
+		}
 	}
 
 	// Prepare Customize Panel objects to pass to JavaScript.
-	foreach ( $wp_customize->panels() as $id => $panel ) {
-		$settings['panels'][ $id ] = $panel->json();
-		foreach ( $panel->sections as $section_id => $section ) {
-			$settings['sections'][ $section_id ] = $section->json();
+	foreach ( $wp_customize->panels() as $panel_id => $panel ) {
+		if ( $panel->check_capabilities() ) {
+			$settings['panels'][ $panel_id ] = $panel->json();
+			foreach ( $panel->sections as $section_id => $section ) {
+				if ( $section->check_capabilities() ) {
+					$settings['sections'][ $section_id ] = $section->json();
+				}
+			}
 		}
 	}
 
diff --git src/wp-admin/js/customize-controls.js src/wp-admin/js/customize-controls.js
index e211a41..6d93d5f 100644
--- src/wp-admin/js/customize-controls.js
+++ src/wp-admin/js/customize-controls.js
@@ -156,6 +156,7 @@
 	Container = api.Class.extend({
 		defaultActiveArguments: { duration: 'fast', completeCallback: $.noop },
 		defaultExpandedArguments: { duration: 'fast', completeCallback: $.noop },
+		containerType: 'container',
 
 		/**
 		 * @since 4.1.0
@@ -168,7 +169,11 @@
 			container.id = id;
 			container.params = {};
 			$.extend( container, options || {} );
+			container.templateSelector = 'customize-' + container.containerType + '-' + container.params.type;
 			container.container = $( container.params.content );
+			if ( 0 === container.container.length ) {
+				container.container = $( container.getContainer() );
+			}
 
 			container.deferred = {
 				embedded: new $.Deferred()
@@ -366,7 +371,26 @@
 		 * Bring the container into view and then expand this and bring it into view
 		 * @param {Object} [params]
 		 */
-		focus: focus
+		focus: focus,
+
+		/**
+		 * Return the container html, generated from its JS template, if it exists.
+		 *
+		 * @since 4.2.0
+		 */
+		getContainer: function () {
+			var template,
+				container = this;
+
+			if ( 0 !== $( '#tmpl-' + container.templateSelector ).length ) {
+				template = wp.template( container.templateSelector );
+				if ( template && container.container ) {
+					return $.trim( template( container.params ) );
+				}
+			}
+
+			return '<li></li>';
+		}
 	});
 
 	/**
@@ -376,6 +400,7 @@
 	 * @augments wp.customize.Class
 	 */
 	api.Section = Container.extend({
+		containerType: 'section',
 
 		/**
 		 * @since 4.1.0
@@ -964,6 +989,8 @@
 	 * @augments wp.customize.Class
 	 */
 	api.Panel = Container.extend({
+		containerType: 'panel',
+
 		/**
 		 * @since 4.1.0
 		 *
@@ -990,6 +1017,7 @@
 
 			if ( ! panel.container.parent().is( parentContainer ) ) {
 				parentContainer.append( panel.container );
+				panel.renderContent();
 			}
 			panel.deferred.embedded.resolve();
 		},
@@ -1012,14 +1040,13 @@
 				}
 			});
 
-			meta = panel.container.find( '.panel-meta:first' );
-
-			meta.find( '> .accordion-section-title' ).on( 'click keydown', function( event ) {
+			panel.container.on( 'click keydown', '.panel-meta > .accordion-section-title', function( event ) {
 				if ( api.utils.isKeydownButNotEnterEvent( event ) ) {
 					return;
 				}
 				event.preventDefault(); // Keep this AFTER the key filter above
 
+				meta = panel.container.find( '.panel-meta' );
 				if ( meta.hasClass( 'cannot-expand' ) ) {
 					return;
 				}
@@ -1142,6 +1169,26 @@
 				panelTitle.focus();
 				container.scrollTop( 0 );
 			}
+		},
+
+		/**
+		 * Render the panel from its JS template, if it exists.
+		 *
+		 * The panel's container must already exist in the DOM.
+		 *
+		 * @since 4.2.0
+		 */
+		renderContent: function () {
+			var template,
+				panel = this;
+
+			// Add the content to the container.
+			if ( 0 !== $( '#tmpl-' + panel.templateSelector + '-content' ).length ) {
+				template = wp.template( panel.templateSelector + '-content' );
+				if ( template && panel.container ) {
+					panel.container.find( '.accordion-sub-container' ).html( template( panel.params ) );
+				}
+			}
 		}
 	});
 
diff --git src/wp-includes/class-wp-customize-manager.php src/wp-includes/class-wp-customize-manager.php
index 98539b0..802d536 100644
--- src/wp-includes/class-wp-customize-manager.php
+++ src/wp-includes/class-wp-customize-manager.php
@@ -60,7 +60,25 @@ final class WP_Customize_Manager {
 	protected $customized;
 
 	/**
-	 * Controls that may be rendered from JS templates.
+	 * Panel types that may be rendered from JS templates.
+	 *
+	 * @since 4.2.0
+	 * @access protected
+	 * @var array
+	 */
+	protected $registered_panel_types = array();
+
+	/**
+	 * Section types that may be rendered from JS templates.
+	 *
+	 * @since 4.2.0
+	 * @access protected
+	 * @var array
+	 */
+	protected $registered_section_types = array();
+
+	/**
+	 * Control types that may be rendered from JS templates.
 	 *
 	 * @since 4.1.0
 	 * @access protected
@@ -612,19 +630,29 @@ final class WP_Customize_Manager {
 		}
 
 		foreach ( $this->settings as $id => $setting ) {
-			$settings['values'][ $id ] = $setting->js_value();
+			if ( $setting->check_capabilities() ) {
+				$settings['values'][ $id ] = $setting->js_value();
+			}
 		}
-		foreach ( $this->panels as $id => $panel ) {
-			$settings['activePanels'][ $id ] = $panel->active();
-			foreach ( $panel->sections as $id => $section ) {
-				$settings['activeSections'][ $id ] = $section->active();
+		foreach ( $this->panels as $panel_id => $panel ) {
+			if ( $panel->check_capabilities() ) {
+				$settings['activePanels'][ $panel_id ] = $panel->active();
+				foreach ( $panel->sections as $section_id => $section ) {
+					if ( $section->check_capabilities() ) {
+						$settings['activeSections'][ $section_id ] = $section->active();
+					}
+				}
 			}
 		}
 		foreach ( $this->sections as $id => $section ) {
-			$settings['activeSections'][ $id ] = $section->active();
+			if ( $section->check_capabilities() ) {
+				$settings['activeSections'][ $id ] = $section->active();
+			}
 		}
 		foreach ( $this->controls as $id => $control ) {
-			$settings['activeControls'][ $id ] = $control->active();
+			if ( $control->check_capabilities() ) {
+				$settings['activeControls'][ $id ] = $control->active();
+			}
 		}
 
 		?>
@@ -965,6 +993,34 @@ final class WP_Customize_Manager {
 	}
 
 	/**
+	 * Register a customize panel type.
+	 *
+	 * Registered types are eligible to be rendered via JS and created dynamically.
+	 *
+	 * @since 4.2.0
+	 * @access public
+	 *
+	 * @param string $panel Name of a custom panel which is a subclass of
+	 *                        {@see WP_Customize_Panel}.
+	 */
+	public function register_panel_type( $panel ) {
+		$this->registered_panel_types[] = $panel;
+	}
+
+	/**
+	 * Render JS templates for all registered panel types.
+	 *
+	 * @since 4.2.0
+	 * @access public
+	 */
+	public function render_panel_templates() {
+		foreach ( $this->registered_panel_types as $panel_type ) {
+			$panel = new $panel_type( $this, 'temp', array() );
+			$panel->print_template();
+		}
+	}
+
+	/**
 	 * Add a customize section.
 	 *
 	 * @since 3.4.0
@@ -1006,6 +1062,34 @@ final class WP_Customize_Manager {
 	}
 
 	/**
+	 * Register a customize section type.
+	 *
+	 * Registered types are eligible to be rendered via JS and created dynamically.
+	 *
+	 * @since 4.2.0
+	 * @access public
+	 *
+	 * @param string $section Name of a custom section which is a subclass of
+	 *                        {@see WP_Customize_Section}.
+	 */
+	public function register_section_type( $section ) {
+		$this->registered_section_types[] = $section;
+	}
+
+	/**
+	 * Render JS templates for all registered section types.
+	 *
+	 * @since 4.2.0
+	 * @access public
+	 */
+	public function render_section_templates() {
+		foreach ( $this->registered_section_types as $section_type ) {
+			$section = new $section_type( $this, 'temp', array() );
+			$section->print_template();
+		}
+	}
+
+	/**
 	 * Add a customize control.
 	 *
 	 * @since 3.4.0
@@ -1176,7 +1260,10 @@ final class WP_Customize_Manager {
 	 */
 	public function register_controls() {
 
-		/* Control Types (custom control classes) */
+		/* Panel, Section, and Control Types */
+		$this->register_panel_type( 'WP_Customize_Panel' );
+		$this->register_section_type( 'WP_Customize_Section' );
+		$this->register_section_type( 'WP_Customize_Sidebar_Section' );
 		$this->register_control_type( 'WP_Customize_Color_Control' );
 		$this->register_control_type( 'WP_Customize_Media_Control' );
 		$this->register_control_type( 'WP_Customize_Upload_Control' );
diff --git src/wp-includes/class-wp-customize-panel.php src/wp-includes/class-wp-customize-panel.php
index ee9f846..f2a8afe 100644
--- src/wp-includes/class-wp-customize-panel.php
+++ src/wp-includes/class-wp-customize-panel.php
@@ -212,7 +212,7 @@ class WP_Customize_Panel {
 	 * @return array The array to be exported to the client as JSON.
 	 */
 	public function json() {
-		$array = wp_array_slice_assoc( (array) $this, array( 'title', 'description', 'priority', 'type' ) );
+		$array = wp_array_slice_assoc( (array) $this, array( 'id', 'title', 'description', 'priority', 'type' ) );
 		$array['content'] = $this->get_content();
 		$array['active'] = $this->active();
 		$array['instanceNumber'] = $this->instance_number;
@@ -287,46 +287,90 @@ class WP_Customize_Panel {
 	}
 
 	/**
-	 * Render the panel container, and then its contents.
+	 * Render the panel container, and then its contents (via `this->render_content()`) in a subclass.
+	 *
+	 * Panel containers are now rendered in JS by default, see {@see WP_Customize_Panel::print_template()}.
 	 *
 	 * @since 4.0.0
 	 * @access protected
 	 */
-	protected function render() {
-		$classes = 'accordion-section control-section control-panel control-panel-' . $this->type;
+	protected function render() {}
+
+	/**
+	 * Render the panel UI in a subclass.
+	 *
+	 * Panel contents are now rendered in JS by default, see {@see WP_Customize_Panel::print_template()}.
+	 *
+	 * @since 4.1.0
+	 * @access protected
+	 */
+	protected function render_content() {}
+
+	/**
+	 * Render the panel's JS templates.
+	 *
+	 * This function is only run for panel types that have been registered with
+	 * {@see WP_Customize_Manager::register_panel_type()}.
+	 *
+	 * @since 4.2.0
+	 */
+	public function print_template() {
 		?>
-		<li id="accordion-panel-<?php echo esc_attr( $this->id ); ?>" class="<?php echo esc_attr( $classes ); ?>">
+		<script type="text/html" id="tmpl-customize-panel-<?php echo esc_attr( $this->type ); ?>-content">
+			<?php $this->content_template(); ?>
+		</script>
+		<script type="text/html" id="tmpl-customize-panel-<?php echo esc_attr( $this->type ); ?>">
+			<?php $this->render_template(); ?>
+		</script>
+        <?php
+	}
+
+	/**
+	 * An Underscore (JS) template for rendering this panel's container.
+	 *
+	 * Class variables for this panel class are available in the `data` JS object;
+	 * export custom variables by overriding {@see WP_Customize_Panel::json()}.
+	 *
+	 * @see WP_Customize_Panel::print_template()
+	 *
+	 * @since 4.2.0
+	 */
+	protected function render_template() {
+		?>
+		<li id="accordion-panel-{{ data.id }}" class="accordion-section control-section control-panel control-panel-{{ data.type }}">
 			<h3 class="accordion-section-title" tabindex="0">
-				<?php echo esc_html( $this->title ); ?>
+				{{ data.title }}
 				<span class="screen-reader-text"><?php _e( 'Press return or enter to open this panel' ); ?></span>
 			</h3>
-			<ul class="accordion-sub-container control-panel-content">
-				<?php $this->render_content(); ?>
-			</ul>
+			<ul class="accordion-sub-container control-panel-content"></ul>
 		</li>
 		<?php
 	}
 
 	/**
-	 * Render the sections that have been added to the panel.
+	 * An Underscore (JS) template for this panel's content (but not its container).
 	 *
-	 * @since 4.1.0
-	 * @access protected
+	 * Class variables for this panel class are available in the `data` JS object;
+	 * export custom variables by overriding {@see WP_Customize_Panel::json()}.
+	 *
+	 * @see WP_Customize_Panel::print_template()
+	 *
+	 * @since 4.2.0
 	 */
-	protected function render_content() {
+	protected function content_template() {
 		?>
-		<li class="panel-meta accordion-section control-section<?php if ( empty( $this->description ) ) { echo ' cannot-expand'; } ?>">
+		<li class="panel-meta accordion-section control-section<# if ( ! data.description ) { #> cannot-expand<# } #>">
 			<div class="accordion-section-title" tabindex="0">
 				<span class="preview-notice"><?php
 					/* translators: %s is the site/panel title in the Customizer */
-					echo sprintf( __( 'You are customizing %s' ), '<strong class="panel-title">' . esc_html( $this->title ) . '</strong>' );
+					echo sprintf( __( 'You are customizing %s' ), '<strong class="panel-title">{{ data.title }}</strong>' );
 				?></span>
 			</div>
-			<?php if ( ! empty( $this->description ) ) : ?>
+			<# if ( data.description ) { #>
 				<div class="accordion-section-content description">
-					<?php echo $this->description; ?>
+					{{{ data.description }}}
 				</div>
-			<?php endif; ?>
+			<# } #>
 		</li>
 		<?php
 	}
diff --git src/wp-includes/class-wp-customize-section.php src/wp-includes/class-wp-customize-section.php
index a27f22b..a113390 100644
--- src/wp-includes/class-wp-customize-section.php
+++ src/wp-includes/class-wp-customize-section.php
@@ -221,7 +221,7 @@ class WP_Customize_Section {
 	 * @return array The array to be exported to the client as JSON.
 	 */
 	public function json() {
-		$array = wp_array_slice_assoc( (array) $this, array( 'title', 'description', 'priority', 'panel', 'type' ) );
+		$array = wp_array_slice_assoc( (array) $this, array( 'id', 'title', 'description', 'priority', 'panel', 'type' ) );
 		$array['content'] = $this->get_content();
 		$array['active'] = $this->active();
 		$array['instanceNumber'] = $this->instance_number;
@@ -249,7 +249,7 @@ class WP_Customize_Section {
 	}
 
 	/**
-	 * Get the section's content template for insertion into the Customizer pane.
+	 * Get the section's content for insertion into the Customizer pane.
 	 *
 	 * @since 4.1.0
 	 *
@@ -295,24 +295,53 @@ class WP_Customize_Section {
 	}
 
 	/**
-	 * Render the section, and the controls that have been added to it.
+	 * Render the section UI in a subclass.
+	 *
+	 * Sections are now rendered in JS by default, see {@see WP_Customize_Section::print_template()}.
 	 *
 	 * @since 3.4.0
 	 */
-	protected function render() {
-		$classes = 'accordion-section control-section control-section-' . $this->type;
+	protected function render() {}
+
+	/**
+	 * Render the section's JS template.
+	 *
+	 * This function is only run for section types that have been registered with
+	 * {@see WP_Customize_Manager::register_section_type()}.
+	 *
+	 * @since 4.2.0
+	 */
+	public function print_template() {
+        ?>
+		<script type="text/html" id="tmpl-customize-section-<?php echo $this->type; ?>">
+			<?php $this->render_template(); ?>
+		</script>
+        <?php
+	}
+
+	/**
+	 * An Underscore (JS) template for rendering this section.
+	 *
+	 * Class variables for this section class are available in the `data` JS object;
+	 * export custom variables by overriding {@see WP_Customize_Section::json()}.
+	 *
+	 * @see WP_Customize_Section::print_template()
+	 *
+	 * @since 4.2.0
+	 */
+	protected function render_template() {
 		?>
-		<li id="accordion-section-<?php echo esc_attr( $this->id ); ?>" class="<?php echo esc_attr( $classes ); ?>">
+		<li id="accordion-section-{{ data.id }}" class="accordion-section control-section control-section-{{ data.type }}">
 			<h3 class="accordion-section-title" tabindex="0">
-				<?php echo esc_html( $this->title ); ?>
+				{{ data.title }}
 				<span class="screen-reader-text"><?php _e( 'Press return or enter to expand' ); ?></span>
 			</h3>
 			<ul class="accordion-section-content">
-				<?php if ( ! empty( $this->description ) ) : ?>
+				<# if ( data.description ) { #>
 					<li class="customize-section-description-container">
-						<p class="description customize-section-description"><?php echo $this->description; ?></p>
+						<p class="description customize-section-description">{{{ data.description }}}</p>
 					</li>
-				<?php endif; ?>
+				<# } #>
 			</ul>
 		</li>
 		<?php
diff --git tests/phpunit/tests/customize/panel.php tests/phpunit/tests/customize/panel.php
new file mode 100644
index 0000000..2eef623
--- /dev/null
+++ tests/phpunit/tests/customize/panel.php
@@ -0,0 +1,204 @@
+<?php
+
+/**
+ * Tests for the WP_Customize_Manager class.
+ *
+ * @group customize
+ */
+class Tests_WP_Customize_Panel extends WP_UnitTestCase {
+
+	/**
+	 * @var WP_Customize_Manager
+	 */
+	protected $manager;
+
+	function setUp() {
+		parent::setUp();
+		require_once( ABSPATH . WPINC . '/class-wp-customize-manager.php' );
+		$GLOBALS['wp_customize'] = new WP_Customize_Manager();
+		$this->manager = $GLOBALS['wp_customize'];
+		$this->undefined = new stdClass();
+	}
+
+	function tearDown() {
+		$this->manager = null;
+		unset( $GLOBALS['wp_customize'] );
+		parent::tearDown();
+	}
+
+	function test_construct_default_args() {
+		$panel = new WP_Customize_Panel( $this->manager, 'foo' );
+		$this->assertInternalType( 'int', $panel->instance_number );
+		$this->assertEquals( $this->manager, $panel->manager );
+		$this->assertEquals( 'foo', $panel->id );
+		$this->assertEquals( 160, $panel->priority );
+		$this->assertEquals( 'edit_theme_options', $panel->capability );
+		$this->assertEquals( '', $panel->theme_supports );
+		$this->assertEquals( '', $panel->title );
+		$this->assertEquals( '', $panel->description );
+		$this->assertEmpty( $panel->sections );
+		$this->assertEquals( 'default', $panel->type );
+		$this->assertEquals( array( $panel, 'active_callback' ), $panel->active_callback );
+	}
+
+	function test_construct_custom_args() {
+		$args = array(
+			'priority' => 200,
+			'capability' => 'edit_posts',
+			'theme_supports' => 'html5',
+			'title' => 'Hello World',
+			'description' => 'Lorem Ipsum',
+			'type' => 'horizontal',
+			'active_callback' => '__return_true',
+		);
+
+		$panel = new WP_Customize_Panel( $this->manager, 'foo', $args );
+		foreach ( $args as $key => $value ) {
+			$this->assertEquals( $value, $panel->$key );
+		}
+	}
+
+	function test_construct_custom_type() {
+		$panel = new Custom_Panel_Test( $this->manager, 'foo' );
+		$this->assertEquals( 'titleless', $panel->type );
+	}
+
+	function test_active() {
+		$panel = new WP_Customize_Panel( $this->manager, 'foo' );
+		$this->assertTrue( $panel->active() );
+
+		$panel = new WP_Customize_Panel( $this->manager, 'foo', array(
+			'active_callback' => '__return_false',
+		) );
+		$this->assertFalse( $panel->active() );
+		add_filter( 'customize_panel_active', array( $this, 'filter_active_test' ), 10, 2 );
+		$this->assertTrue( $panel->active() );
+	}
+
+	/**
+	 * @param bool $active
+	 * @param WP_Customize_Panel $panel
+	 * @return bool
+	 */
+	function filter_active_test( $active, $panel ) {
+		$this->assertFalse( $active );
+		$this->assertInstanceOf( 'WP_Customize_Panel', $panel );
+		$active = true;
+		return $active;
+	}
+
+	function test_json() {
+		$args = array(
+			'priority' => 200,
+			'capability' => 'edit_posts',
+			'theme_supports' => 'html5',
+			'title' => 'Hello World',
+			'description' => 'Lorem Ipsum',
+			'type' => 'horizontal',
+			'active_callback' => '__return_true',
+		);
+		$panel = new WP_Customize_Panel( $this->manager, 'foo', $args );
+		$data = $panel->json();
+		$this->assertEquals( 'foo', $data['id'] );
+		foreach ( array( 'title', 'description', 'priority', 'type' ) as $key ) {
+			$this->assertEquals( $args[ $key ], $data[ $key ] );
+		}
+		$this->assertEmpty( $data['content'] );
+		$this->assertTrue( $data['active'] );
+		$this->assertInternalType( 'int', $data['instanceNumber'] );
+	}
+
+	function test_check_capabilities() {
+		$user_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $user_id );
+
+		$panel = new WP_Customize_Panel( $this->manager, 'foo' );
+		$this->assertTrue( $panel->check_capabilities() );
+		$old_cap = $panel->capability;
+		$panel->capability = 'do_not_allow';
+		$this->assertFalse( $panel->check_capabilities() );
+		$panel->capability = $old_cap;
+		$this->assertTrue( $panel->check_capabilities() );
+		$panel->theme_supports = 'impossible_feature';
+		$this->assertFalse( $panel->check_capabilities() );
+	}
+
+	function test_get_content() {
+		$panel = new WP_Customize_Panel( $this->manager, 'foo' );
+		$this->assertEmpty( $panel->get_content() );
+	}
+
+	function test_maybe_render() {
+		wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) );
+		$panel = new WP_Customize_Panel( $this->manager, 'bar' );
+		$customize_render_panel_count = did_action( 'customize_render_panel' );
+		add_action( 'customize_render_panel', array( $this, 'action_customize_render_panel_test' ) );
+		ob_start();
+		$panel->maybe_render();
+		$content = ob_get_clean();
+		$this->assertTrue( $panel->check_capabilities() );
+		$this->assertEmpty( $content );
+		$this->assertEquals( $customize_render_panel_count + 1, did_action( 'customize_render_panel' ), 'Unexpected did_action count for customize_render_panel' );
+		$this->assertEquals( 1, did_action( "customize_render_panel_{$panel->id}" ), "Unexpected did_action count for customize_render_panel_{$panel->id}" );
+	}
+
+	function action_customize_render_panel_test( $panel ) {
+		$this->assertInstanceOf( 'WP_Customize_Panel', $panel );
+	}
+
+	function test_print_templates_standard() {
+		wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) );
+
+		$panel = new WP_Customize_Panel( $this->manager, 'baz' );
+		ob_start();
+		$panel->print_template();
+		$content = ob_get_clean();
+		$this->assertContains( '<script type="text/html" id="tmpl-customize-panel-default-content">', $content );
+		$this->assertContains( 'accordion-section-title', $content );
+		$this->assertContains( 'control-panel-content', $content );
+		$this->assertContains( '<script type="text/html" id="tmpl-customize-panel-default">', $content );
+		$this->assertContains( 'accordion-section-content', $content );
+		$this->assertContains( 'preview-notice', $content );
+	}
+
+
+	function test_print_templates_custom() {
+		wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) );
+
+		$panel = new Custom_Panel_Test( $this->manager, 'baz' );
+		ob_start();
+		$panel->print_template();
+		$content = ob_get_clean();
+		$this->assertContains( '<script type="text/html" id="tmpl-customize-panel-titleless-content">', $content );
+		$this->assertNotContains( 'accordion-section-title', $content );
+
+		$this->assertContains( '<script type="text/html" id="tmpl-customize-panel-titleless">', $content );
+		$this->assertNotContains( 'preview-notice', $content );
+	}
+}
+
+require_once ABSPATH . WPINC . '/class-wp-customize-panel.php';
+class Custom_Panel_Test extends WP_Customize_Panel {
+	public $type = 'titleless';
+
+	protected function render_template() {
+		?>
+		<li id="accordion-panel-{{ data.id }}" class="accordion-section control-section control-panel control-panel-{{ data.type }}">
+			<ul class="accordion-sub-container control-panel-content"></ul>
+		</li>
+		<?php
+	}
+
+	protected function content_template() {
+		?>
+		<li class="panel-meta accordion-section control-section<# if ( ! data.description ) { #> cannot-expand<# } #>">
+			<# if ( data.description ) { #>
+				<div class="accordion-section-content description">
+					{{{ data.description }}}
+				</div>
+			<# } #>
+		</li>
+		<?php
+	}
+
+}
diff --git tests/qunit/wp-admin/js/customize-controls.js tests/qunit/wp-admin/js/customize-controls.js
index cb8767f..54818d2 100644
--- tests/qunit/wp-admin/js/customize-controls.js
+++ tests/qunit/wp-admin/js/customize-controls.js
@@ -109,6 +109,8 @@ jQuery( window ).load( function (){
 		equal( section.panel(), 'fixture-panel' );
 	} );
 
+	// @todo ensure that custom section type can be instantiated properly from the template
+
 	module( 'Customizer Panel in Fixture' );
 	test( 'Fixture panel exists', function () {
 		ok( wp.customize.panel.has( 'fixture-panel' ) );
@@ -138,6 +140,8 @@ jQuery( window ).load( function (){
 		ok( panel.expanded() );
 	} );
 
+	// @todo ensure that custom panel type can be instantiated properly from the template
+
 
 	module( 'Dynamically-created Customizer Setting Model' );
 	settingId = 'new_blogname';
