Skip to content

Build Your Own Block ​

This guide shows how to turn Base Kit components into a maintainable, installable, and optionally contributable business block. The running example is an anonymized filter bar with no real APIs, credentials, private URLs, or internal ticket IDs.

Installable Blocks ​

1. Understand the layers ​

LayerOwnsDoes not own
PrimitivesState, events, parts, cross-runtime semanticsVisual tokens, product copy
Base Kit / UICopied low-level component source and default stylesRemote data, permissions, domain models
Business wrappersAPIs, authz, field mapping, product copyGeneric interaction state machines
BlocksReusable screen sections and local compositionPrivate backend details, one-off page glue

Rule of thumb: a block should feel like a portable page slice, not a page locked to one product API.

2. Define the block contract ​

Write the contract before the implementation:

ts
// Example contract for a local filter block
export interface StatusOption {
  label: string
  value: string
  disabled?: boolean
}

export interface FilterBarProps {
  /** Status options injected by the product layer */
  options: StatusOption[]
  /** Controlled selected values */
  modelValue?: string[]
  /** Disabled state */
  disabled?: boolean
}

export interface FilterBarEmits {
  (e: 'update:modelValue', value: string[]): void
  (e: 'change', value: string[]): void
}

Capture:

  • Inputs: options, defaults, disabled
  • Outputs: update:modelValue / change
  • Local state: UI-only temporary state
  • Dependencies: Base Kit component names such as select
  • Exclusions: remote dictionaries, auth, analytics SDKs, private domains

3. Choose Base Kit dependencies ​

Prefer dependencies from the app tree:

text
src/components/ui/*

Do not import registry source paths directly from the block. Keep remote data and product policy in business wrappers.

Install the base component:

bash
pnpm dlx @varo-ui/cli add --target weapp components/select

4. Create the local block ​

Recommended path:

text
src/components/blocks/status-filter.vue

Minimal implementation:

vue
<script setup lang="ts">
import { computed } from 'vue'
import { VSelect } from '../ui/select'

export interface StatusOption {
  label: string
  value: string
  disabled?: boolean
}

const props = withDefaults(
  defineProps<{
    options: StatusOption[]
    modelValue?: string[]
    disabled?: boolean
    placeholder?: string
  }>(),
  {
    modelValue: () => [],
    disabled: false,
    placeholder: 'Select statuses'
  }
)

const emit = defineEmits<{
  'update:modelValue': [value: string[]]
  'change': [value: string[]]
}>()

const value = computed({
  get: () => props.modelValue,
  set: (next) => {
    const normalized = next ?? []
    emit('update:modelValue', normalized)
    emit('change', normalized)
  }
})
</script>

<template>
  <section class="status-filter">
    <header class="status-filter__head">
      <strong>Status filter</strong>
      <span>{{ value.length }} selected</span>
    </header>
    <VSelect
      v-model:value="value"
      multiple
      :disabled="disabled"
      :options="options"
      :placeholder="placeholder"
    />
  </section>
</template>

Requirements:

  • typed props and emits
  • small, readable layout
  • no hard-coded product APIs

5. Test the block ​

Cover at least:

  • initial render
  • emitted values after interaction
  • disabled behavior
  • dependency boundaries (no network, no private env reads)
ts
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import StatusFilter from '../status-filter.vue'

describe('StatusFilter', () => {
  it('keeps selected statuses under product control', async () => {
    const wrapper = mount(StatusFilter, {
      props: {
        options: [
          { label: 'Pending', value: 'pending' },
          { label: 'Done', value: 'done' }
        ],
        modelValue: []
      }
    })

    await wrapper.setProps({ modelValue: ['pending'] })
    expect(wrapper.props('modelValue')).toEqual(['pending'])
  })
})

6. Use and maintain it locally ​

vue
<script setup lang="ts">
import { ref } from 'vue'
import StatusFilter from '@/components/blocks/status-filter.vue'

const statuses = ref<string[]>([])
const options = [
  { label: 'Pending', value: 'pending' },
  { label: 'Done', value: 'done' }
]
</script>

<template>
  <StatusFilter v-model="statuses" :options="options" />
</template>

Copied source belongs to the application team. You can change layout, tokens, and interaction without contributing those product decisions back to Varo.

7. Prepare registry metadata ​

To make the block installable through the CLI:

text
registry/blocks/status-filter/registry.json
registry/blocks/status-filter/status-filter.vue

Example registry.json:

json
{
  "name": "status-filter",
  "type": "block",
  "title": "Status Filter",
  "description": "A local filter bar for selecting multiple anonymized statuses with VSelect.",
  "targets": ["weapp"],
  "dependencies": ["vue"],
  "registryDependencies": ["components/select"],
  "files": [
    {
      "target": "weapp",
      "from": "registry/blocks/status-filter/status-filter.vue",
      "to": "src/components/blocks/status-filter.vue"
    }
  ],
  "docs": "/blocks/status-filter"
}

Key fields:

  • type: block
  • targets: for example weapp
  • registryDependencies: recursive base components
  • files: from/to mappings
  • docs: documentation route

8. Add bilingual documentation ​

Every public block should document:

  • purpose
  • dependencies
  • target
  • installed file destination
  • customization boundary / excluded business logic

Existing examples:

9. Verify installation ​

Suggested checklist:

bash
# types and tests
pnpm typecheck
pnpm test

# pack the CLI and install into a temporary fixture
pnpm --filter @varo-ui/cli build
pnpm dlx @varo-ui/cli add --registry ./registry --target weapp blocks/status-filter

Confirm:

  • dependency components install recursively
  • existing destinations are not clobbered by default
  • overwrite requires an explicit force path
  • installed files contain no private domains, tokens, or internal IDs

Publish an independent registry ​

No upstream contribution is required. Host the registry/ directory, including manifests and source for every transitive dependency, on your own static site:

bash
pnpm dlx @varo-ui/cli add --registry https://ui.example.com/registry/ --target weapp blocks/status-filter

The CLI loads blocks/status-filter/registry.json below that URL. Source URLs use files.from with the leading registry/ removed. Both ordinary and target-specific registryDependencies resolve within the selected registry; missing dependencies fail rather than falling back to Varo's bundled registry. Use --registry ./registry to verify a local directory.

Only install sources you trust. Remote roots use HTTP(S), cannot contain credentials, queries, or fragments, and do not follow redirects. Each response is limited to 10 MiB and 30 seconds. Installs retain src/ confinement, symlink checks, collision checks, and rollback. npm dependencies are reported, not installed.

Use standard shadcn-vue manifests directly ​

Standard registry.json catalogs (name, homepage, items) and individual registry-item.json definitions are accepted without adding Varo from/to/targets fields:

bash
pnpm dlx @varo-ui/cli add --registry ./registry.json hello-world
pnpm dlx @varo-ui/cli add --registry ./hello-world.json hello-world
pnpm dlx @varo-ui/cli add --registry https://ui.example.com/r/registry.json hello-world
pnpm dlx @varo-ui/cli export --registry ./registry.json hello-world > hello-world.json

files.path is relative to the manifest directory; published files with inline content need no backing source file. Components, UI, hooks, and libs default to src/components, src/components/ui, src/composables, and src/lib, preserving nested component directories. Explicit target wins but must stay inside src/. Consumer components.json and TypeScript/JSONC aliases are honored, and JS/TS/Vue script imports between relocated files are rewritten through AST parsing.

Standard manifests default to H5; Weapp items must declare meta.varo.target: "weapp". Catalog dependencies resolve by name, individual items can load sibling <name>.json files, and cross-registry dependencies use explicit HTTP(S) item URLs. Supported file semantics cover registry:block/component/ui/hook/composable/lib/page/file/theme/style; page/file entries require an explicit target. registry:base/font, framework conversion, and npm auto-install are unsupported. css, cssVars, tailwind, envVars, and style inheritance through extends fail explicitly rather than being silently ignored. There is no implicit public-registry fallback.

Install through the shadcn-vue ecosystem ​

Exporting one item inlines its selected target's complete dependency closure and source into a shadcn-vue registry item:

bash
mkdir -p public/r
pnpm dlx @varo-ui/cli export --registry ./registry --target weapp blocks/status-filter > public/r/status-filter.json
# Host public/, then run in a consumer configured with components.json and TypeScript path aliases:
pnpm dlx shadcn-vue@latest add https://ui.example.com/r/status-filter.json

The payload uses registry:file, inline content, and explicit ~/src/... destinations. shadcn-vue and tools delegating installation to it can consume this JSON; compatibility was exercised with shadcn-vue 2.8.2. Varo's add --registry can also read the exported single-item JSON directly.

Exports require valid UTF-8 contents; non-UTF-8 bytes fail explicitly instead of being silently replaced. add still copies binary assets byte-for-byte. Installation and export both reject destinations where a file is also another file's parent directory, including case-insensitive conflicts.

Export H5 and Weapp separately. meta.varo.target records the target, but external installers do not enforce it or convert Vue to Wevu. Consumers still need matching runtime dependencies and theme setup. Programmatic CLI consumers must use await resolveRegistryItems(...): both local and remote resolution now return a Promise.

10. Contribute to Varo ​

Upstream contribution is optional, not a prerequisite for publishing a third-party registry.

Privacy and portability checklist:

A contribution usually includes source, registry.json, tests, and bilingual docs.

Troubleshooting ​

SymptomFix
Missing dependency installCheck registryDependencies spelling such as components/select
Destination conflictReview the to path; default is no-clobber, force only when explicit
Hard to reuseMove remote data/auth/analytics into a business wrapper
Breaks on mini-programRemove H5-only APIs and keep the shared contract
Unit tests pass but install failsReproduce with packed CLI + temp fixture; verify file mappings

Install existing blocks ​

bash
pnpm dlx @varo-ui/cli add --target weapp blocks/profile-edit
pnpm dlx @varo-ui/cli add --target weapp blocks/order-filter

After install:

text
src/components/ui/*        # base component source
src/components/blocks/*    # block source
src/components/biz/*       # recommended home for product wrappers