State & Controls
Controls give you the ability to interact with your components arguments.
Custom #controls slots run in a dedicated sandbox. Their background, typography and color scheme follow the Histoire panel. Built-in dropdowns and tooltips render in the host window, so they can extend beyond the controls form.
Shared component contracts
Builtin Hst* controls use C1 styling with bundled Manrope and JetBrains Mono fonts. No font requests leave the book. Provider appearance and density stay local; standalone controls receive C1 defaults.
Wrapped fields accept layout="stacked", layout="horizontal", or layout="inline". Text, number, textarea, select, JSON and button groups default to stacked labels; checkbox and switch rows default to horizontal. Inline fields fit toolbars and external labels without reserving label space. Prop type metadata uses data-histoire-control-type in normal label flow.
HstText accepts native type, including search and password. Native input attributes/listeners reach the field once. Interactive controls expose focus(); text, textarea, number and color text fields also expose select().
HstSwitch edits Boolean state with native switch semantics. HstCheckbox retains Boolean and string Boolean compatibility. HstSelect and HstButtonGroup accept string/numeric arrays, records and { value, label, disabled? } options. Values retain their types and local object identity. Select adds disabled, placeholder, selected-label default slot and option slot. Rich slots stay local; host menus receive labels and opaque IDs only. Disabled options remain visible and cannot be selected.
Both controls entrypoints keep existing exports. Native Vue hosts import @histoire/controls/vue and its provider-scoped stylesheet; JavaScript import alone does not install global styles or theme state. Preview components retain their own styling.
Custom overlays
Use getControlsHost() inside a custom controls component to display a text tooltip or a dropdown in the host. The adapter is available only in the controls sandbox. Keep option values in your component and pass string IDs and labels to the host.
<script setup lang="ts">
import type { HistoireControlsOverlayHandle } from 'histoire/client'
import { getControlsHost } from 'histoire/client'
import { onBeforeUnmount, ref } from 'vue'
const anchor = ref<HTMLButtonElement>()
let overlay: HistoireControlsOverlayHandle | undefined
function showTooltip() {
if (!anchor.value) return
overlay?.close()
overlay = getControlsHost()?.open(anchor.value, {
kind: 'tooltip',
content: 'Custom control',
placement: 'top',
}, () => { overlay = undefined })
}
function hideTooltip() {
overlay?.close()
overlay = undefined
}
onBeforeUnmount(hideTooltip)
</script>
<template>
<button ref="anchor" @mouseenter="showTooltip" @mouseleave="hideTooltip">
Custom control
</button>
</template>Call handle.update(overlay) when content changes and handle.close() on unmount. Dropdown overlays use { kind: 'select', label, items: [{ id, label }], selectedId }; the callback receives itemId on selection. Content is plain text. Third-party popovers rendering their own DOM remain inside the sandbox unless adapted to this API.
Defining a state
The first step is to define the state that will be shared to your story. Histoire will automatically synchronize the data or reactive data returned in your setup. Then you can proceed using your state as usual.
Example with Option API:
<script lang="ts">
import { defineComponent } from 'vue'
import MyButton from './MyButton.vue'
export default defineComponent({
components: {
MyButton,
},
data () {
// Histoire will inspect and synchronize this
return {
state: {
disabled: false,
content: 'Hello world',
},
message: 'Meow!',
}
},
})
</script>
<template>
<Story>
<Variant>
<MyButton :disabled="state.disabled">
{{ state.content }}
</MyButton>
<input v-model.number="message">
</Variant>
</Story>
</template>Example with Composition API:
<script lang="ts">
import { reactive, ref, defineComponent } from 'vue'
import MyButton from './MyButton.vue'
export default defineComponent({
components: {
MyButton,
},
setup () {
const state = reactive({
disabled: false,
content: 'Hello world',
})
const message = ref('Meow!')
// Histoire will inspect and synchronize this
return {
state,
message,
}
}
})
</script>
<template>
<Story>
<Variant>
<MyButton :disabled="state.disabled">
{{ state.content }}
</MyButton>
<input v-model.number="message">
</Variant>
</Story>
</template>Example with Composition API (Script Setup):
<script lang="ts" setup>
import { reactive, ref } from 'vue'
import MyButton from './MyButton.vue'
const state = reactive({
disabled: false,
content: 'Hello world',
})
const message = ref('Meow!')
</script>
<template>
<Story>
<Variant>
<MyButton :disabled="state.disabled">
{{ state.content }}
</MyButton>
<input v-model.number="message">
</Variant>
</Story>
</template>It can also be useful to declare some data that isn't going to be reactive, for example some fixture data or configuration:
<script lang="ts" setup>
import { reactive } from 'vue'
import MyButton from './MyButton.vue'
// Main reactive state of the stories
const state = reactive({
colorId: 'primary',
})
// Some fixture/configuration data
const colors = {
primary: '#f00',
secondary: '#0f0',
// ...
}
</script>
<template>
<Story>
<Variant>
<MyButton :color="colors[state.colorId]">
{{ state.colorId }}
</MyButton>
</Variant>
</Story>
</template>Controls panel
To create the control panel, Histoire provides a controls slot. You are free to render any element or components inside the slot.
<script lang="ts" setup>
import { reactive } from 'vue'
import MyButton from './MyButton.vue'
const state = reactive({
disabled: false,
content: 'Hello world',
})
</script>
<template>
<Story>
<Variant>
<MyButton :disabled="state.disabled">
{{ state.content }}
</MyButton>
<template #controls>
Content: <input type="text" v-model="state.content" />
Disabled: <input type="checkbox" v-model="state.disabled" />
</template>
</Variant>
</Story>
</template>You can also share the same default controls for all variants by putting the slot directly under the <Story> component:
<template>
<Story>
<template #controls>
Content: <input type="text" v-model="state.content" />
Disabled: <input type="checkbox" v-model="state.disabled" />
</template>
<Variant>
<MyButton :disabled="state.disabled">
{{ state.content }}
</MyButton>
<!-- Reusing controls -->
</Variant>
<Variant>
<MyButton :disabled="state.disabled">
{{ state.content }}
</MyButton>
<!-- Reusing controls -->
</Variant>
</Story>
</template>A variant can then override the slot if needed.
Builtin controls
To build a control panel a bit more easily, Histoire provides builtin controls with design that fits the rest of the UI.
<script lang="ts" setup>
import { reactive } from 'vue'
import MyButton from './MyButton.vue'
const state = reactive({
disabled: false,
content: 'Hello world',
})
</script>
<template>
<Story>
<Variant>
<MyButton :disabled="state.disabled">
{{ state.content }}
</MyButton>
<template #controls>
<HstText v-model="state.content" title="Content" />
<HstCheckbox v-model="state.disabled" title="Disabled" />
</template>
</Variant>
</Story>
</template>Check out all the available controls in their book: controls.histoire.dev.
Init state
As an alternative to the above, you can pass an initState prop to the Story or Variant, which should be a function returning a state object. It's useful to have different states for variants in the same story and to be a bit more explicit at the expense of being more verbose.
You can then use the state slot props on the <Variant> slots to access the state.
Example:
<script lang="ts" setup>
function initState () {
return {
count: 0,
text: '',
}
}
function initState2 () {
return {
meow: {
foo: 'bar',
},
}
}
</script>
<template>
<Story
title="State"
>
<Variant
title="default"
:init-state="initState"
>
<template #default="{ state }">
<h1>State</h1>
<div>
<pre>{{ state }}</pre>
<input
v-model.number="state.count"
type="number"
>
<input
v-model="state.text"
>
</div>
</template>
<template #controls="{ state }">
<div class="controls">
<button @click="state.count--">
-1
</button>
<button @click="state.count++">
+1
</button>
<span>{{ state.count }}</span>
</div>
<HstText
v-model="state.text"
title="Text"
/>
</template>
</Variant>
<Variant
title="Nested state object"
:init-state="initState2"
>
<template #default="{ state }">
<input v-model="state.meow.foo">
</template>
<template #controls="{ state }">
<HstText
v-model="state.meow.foo"
title="meow.foo"
/>
</template>
</Variant>
</Story>
</template>