JSON viewer

Any value as a tree you can open and close, with each kind of value in its own colour and a count on every closed branch.

Install

If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.

npx ermineui init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add json-viewer
pnpm dlx ermineui add json-viewer
yarn dlx ermineui add json-viewer
bunx ermineui add json-viewer

That copies 3 files into src/components/ui/json-viewer/. Files you already have are kept.

Use

import { JsonViewer } from '@/components/ui/json-viewer'
<JsonViewer :value="payload" :expand-depth="2" />
<DiffViewer :before="oldText" :after="newText" view="split" title="config.ts" />

<MediaPlayer title="Product tour" src="/tour.mp4" poster="/tour.jpg">
  <track kind="captions" src="/tour.vtt" srclang="en" label="English" />
</MediaPlayer>

<Marquee label="Customers"><img v-for="logo in logos" :key="logo.name" :src="logo.src" :alt="logo.name" /></Marquee>

In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.

What gets copied

FileWhere it goes
JsonNode.vuesrc/components/ui/json-viewer/JsonNode.vue
JsonViewer.vuesrc/components/ui/json-viewer/JsonViewer.vue
index.tssrc/components/ui/json-viewer/index.ts

The folders are the suggested ones. ermine.json says where yours are.

Source

This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.

JsonNode.vue 59 lines
<script setup lang="ts">
import { computed, ref } from 'vue'

const props = defineProps<{
  value: unknown
  /** The key this value sits under. Items of an array have none. */
  name?: string
  depth: number
  expandDepth: number
  /** The last one in its parent, so no comma follows. */
  last?: boolean
}>()

const type = computed(() => {
  const value = props.value
  if (value === null || value === undefined) return 'null'
  if (Array.isArray(value)) return 'array'
  return typeof value === 'object' ? 'object' : typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? typeof value : 'string'
})
const branch = computed(() => type.value === 'array' || type.value === 'object')
const entries = computed<Array<[string | undefined, unknown]>>(() => {
  if (type.value === 'array') return (props.value as unknown[]).map((item) => [undefined, item])
  if (type.value === 'object') return Object.entries(props.value as Record<string, unknown>)
  return []
})
const brackets = computed(() => (type.value === 'array' ? ['[', ']'] : ['{', '}']))
const count = computed(() => `${entries.value.length} ${type.value === 'array' ? (entries.value.length === 1 ? 'item' : 'items') : entries.value.length === 1 ? 'key' : 'keys'}`)

const open = ref(props.depth < props.expandDepth)
</script>

<template>
  <li class="er-json-node">
    <template v-if="branch && entries.length">
      <button type="button" class="er-json-node-toggle" :aria-expanded="open" @click="open = !open">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6" /></svg>
        <span v-if="name !== undefined" class="er-json-node-key">{{ JSON.stringify(name) }}<span class="er-json-node-punctuation">: </span></span>
        <span class="er-json-node-punctuation">{{ brackets[0] }}</span>
        <template v-if="!open">
          <span class="er-json-node-count">{{ count }}</span>
          <span class="er-json-node-punctuation">{{ brackets[1] }}{{ last ? '' : ',' }}</span>
        </template>
      </button>
      <template v-if="open">
        <ul>
          <JsonNode v-for="([key, item], index) in entries" :key="key ?? index" :value="item" :name="key" :depth="depth + 1" :expand-depth="expandDepth" :last="index === entries.length - 1" />
        </ul>
        <span class="er-json-node-punctuation">{{ brackets[1] }}{{ last ? '' : ',' }}</span>
      </template>
    </template>
    <template v-else>
      <span v-if="name !== undefined" class="er-json-node-key">{{ JSON.stringify(name) }}<span class="er-json-node-punctuation">: </span></span>
      <span v-if="branch" class="er-json-node-punctuation">{{ brackets.join('') }}</span>
      <span v-else class="er-json-node-value" :data-type="type">{{ type === 'null' ? 'null' : JSON.stringify(value) }}</span>
      <span v-if="!last" class="er-json-node-punctuation">,</span>
    </template>
  </li>
</template>
JsonViewer.vue 127 lines
<script setup lang="ts">
import JsonNode from './JsonNode.vue'

withDefaults(
  defineProps<{
    /** Anything JSON can hold: an object, an array, text, a number, true, false or null. */
    value: unknown
    /** How many levels start open. 0 shows only the outermost brackets. */
    expandDepth?: number
    /** Accessible name of the tree. */
    label?: string
    /** What the outermost value is called. Leave out to show no name for it. */
    rootName?: string
  }>(),
  { expandDepth: 1, label: 'JSON' },
)
</script>

<template>
  <div class="er-json-viewer" role="group" :aria-label="label">
    <ul>
      <JsonNode :value="value" :name="rootName" :depth="0" :expand-depth="expandDepth" last />
    </ul>
  </div>
</template>

<style>
@layer components {
  .er-json-viewer {
    width: 100%;
    padding: 0.75rem 1rem;
    border: 1px solid var(--er-border);
    border-radius: var(--er-radius-lg);
    background: var(--er-card);
    color: var(--er-card-foreground);
    font-family: var(--er-font-mono);
    font-size: 0.8125rem;
    line-height: 1.5rem;
    overflow-x: auto;
  }

  .er-json-viewer ul {
    margin: 0;
    padding: 0;
    list-style: none;
  }

  .er-json-viewer ul ul {
    margin-left: 0.4375rem;
    padding-left: 1rem;
    border-left: 1px solid var(--er-border);
  }

  .er-json-viewer li {
    white-space: nowrap;
  }

  .er-json-node-toggle {
    display: inline-flex;
    align-items: center;
    gap: 0.25rem;
    margin-left: -1.125rem;
    padding: 0;
    border: 0;
    border-radius: var(--er-radius-sm);
    background: transparent;
    color: inherit;
    font: inherit;
    cursor: pointer;
  }

  /* The outermost value has nothing to its left to hang the arrow in. */
  .er-json-viewer > ul > li > .er-json-node-toggle {
    margin-left: 0;
  }

  .er-json-node-toggle:focus-visible {
    outline: 2px solid var(--er-ring);
    outline-offset: 1px;
  }

  .er-json-node-toggle svg {
    width: 0.875rem;
    height: 0.875rem;
    color: var(--er-muted-foreground);
    transition: transform var(--er-duration);
  }

  .er-json-node-toggle[aria-expanded='true'] svg {
    transform: rotate(90deg);
  }

  .er-json-node-key {
    color: var(--er-foreground);
  }

  .er-json-node-punctuation,
  .er-json-node-count {
    color: var(--er-muted-foreground);
  }

  .er-json-node-count {
    font-style: italic;
  }

  /* Each kind of value has its own colour, and its own shape as text: quotes, digits, words. */
  .er-json-node-value[data-type='string'] {
    color: var(--er-success);
    white-space: pre-wrap;
    overflow-wrap: anywhere;
  }

  .er-json-node-value[data-type='number'] {
    color: var(--er-info);
  }

  /* The warning colour alone is too pale to read as text, so it is darkened towards the text colour. */
  .er-json-node-value[data-type='boolean'] {
    color: color-mix(in oklab, var(--er-warning) 65%, var(--er-foreground));
  }

  .er-json-node-value[data-type='null'] {
    color: var(--er-muted-foreground);
  }
}
</style>
index.ts 3 lines
/** JSON viewer — any value as a tree you can open and close, with each kind of value in its own colour and a count on every closed branch. */
export { default as JsonViewer } from './JsonViewer.vue'