A generic pattern using top level await without side-effects:
my-component/
  element.js
  template.html
  styles.css
template.html (be sure to link to styles.css)
<template>
  <link rel="stylesheet" href="./styles.css" />
  <!-- other HTML/Slots/Etc. -->
  <slot></slot>
</template>
styles.css (regular CSS file)
:host {
  border: 1px solid red;
}
element.js (uses top level await in export)
const setup = async () => {
  const parser = new DOMParser()
  const resp = await fetch('./template.html')
  const html = await resp.text()
  const template = parser.parseFromString(html, 'text/html').querySelector('template')
  return class MyComponent extends HTMLElement {
    constructor() {
      super()
      this.attachShadow({ mode: 'open'}).appendChild(template.content.cloneNode(true))
    }
    // Rest of element implementation...
  }
}
export default await setup()
index.html (loading and defining the element)
<!doctype html>
<html>
  <head>
    <title>Custom Element Separate Files</title>
    <script type="module">
      import MyComponent from './element.js'
      if (!customElements.get('my-component')) {
        customElements.define('my-component', MyComponent)
      }
    </script>
  </head>
  <body>
    <my-component>hello world</my-component>
  </body>
</html>
You can and should make side-effects (like registering a custom element in the global scope) explicit. Aside from creating some init function to call on your element, you can also provide a distinct import path, for example:
defined.js (sibling to element.js)
import MyComponent from './element.js'
const define = async () => {
  let ctor = null
  customElements.define('my-component', MyComponent)
  ctor = await customElements.whenDefined('my-component')
  return ctor
}
export default await define()
index.html (side-effect made explicit via import path)
<!doctype html>
<html>
  <head>
    <title>Custom Element Separate Files</title>
    <script type="module" src="./defined.js"></script>
  </head>
  <body>
    <my-component>hello world</my-component>
  </body>
</html>
This approach can also support arbitrary names when defining the custom element by including something like this inside define:
new URL(import.meta.url).searchParams.get('name')
and then passing the name query param in the import specifier:
<script type="module" src="./defined.js?name=custom-name"></script>
<custom-name>hello</custom-name>
Here's an example snippet using tts-element that combines all three approaches (see the network tab in dev console):
<!DOCTYPE html>
<html lang="en-US">
  <head>
    <meta charset="utf-8" />
    <title>tts-element combined example</title>
    <style>
      text-to-speech:not(:defined), my-tts:not(:defined), speech-synth:not(:defined) {
        display: none;
      }
    </style>
    <script type="module" src="https://unpkg.com/tts-element/dist/text-to-speech/defined.js"></script>
    <script type="module" src="https://cdn.jsdelivr.net/npm/tts-element@0.0.3-beta/dist/text-to-speech/defined.js?name=my-tts"></script>
    <script type="module">
      import ctor from 'https://unpkg.com/tts-element/dist/text-to-speech/element.js'
      customElements.define('speech-synth', ctor)
    </script>
  </head>
  <body>
    <text-to-speech>Your run-of-the-mill text-to-speech example.</text-to-speech>
    <my-tts>Example using the "name" query parameter.</my-tts>
    <speech-synth>Example using element.js.</speech-synth>
  </body>
</html>